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
data/lib/ibex/analysis/sets.rb
CHANGED
|
@@ -7,6 +7,11 @@ module Ibex
|
|
|
7
7
|
attr_reader :nullable_bits #: Integer
|
|
8
8
|
attr_reader :first_bits #: Array[Integer]
|
|
9
9
|
attr_reader :follow_bits #: Array[Integer]
|
|
10
|
+
# The index is a symbol id; each value lists the symbols that must be
|
|
11
|
+
# recomputed when the indexed symbol's set changes. These edges already
|
|
12
|
+
# point in the impact-propagation direction.
|
|
13
|
+
attr_reader :first_dependencies #: Array[Array[Integer]]
|
|
14
|
+
attr_reader :follow_dependencies #: Array[Array[Integer]]
|
|
10
15
|
|
|
11
16
|
# @rbs @grammar: IR::Grammar
|
|
12
17
|
|
|
@@ -16,6 +21,8 @@ module Ibex
|
|
|
16
21
|
@nullable_bits = 0
|
|
17
22
|
@first_bits = Array.new(grammar.symbols.length, 0)
|
|
18
23
|
@follow_bits = Array.new(grammar.symbols.length, 0)
|
|
24
|
+
@first_dependencies = [] #: Array[Array[Integer]]
|
|
25
|
+
@follow_dependencies = [] #: Array[Array[Integer]]
|
|
19
26
|
grammar.terminals.each { |terminal| @first_bits[terminal.id] = bit(terminal.id) }
|
|
20
27
|
compute_nullable
|
|
21
28
|
compute_first
|
|
@@ -99,6 +106,7 @@ module Ibex
|
|
|
99
106
|
end
|
|
100
107
|
end
|
|
101
108
|
|
|
109
|
+
@first_dependencies = freeze_dependencies(dependencies)
|
|
102
110
|
propagate_bits(@first_bits, dependencies, @grammar.terminals.map(&:id))
|
|
103
111
|
end
|
|
104
112
|
|
|
@@ -112,10 +120,16 @@ module Ibex
|
|
|
112
120
|
end
|
|
113
121
|
dependencies = Array.new(@grammar.symbols.length) { [] }
|
|
114
122
|
@grammar.productions.each { |production| initialize_follow(production, dependencies) }
|
|
123
|
+
@follow_dependencies = freeze_dependencies(dependencies)
|
|
115
124
|
seeds = @grammar.nonterminals.filter_map { |symbol| symbol.id unless @follow_bits[symbol.id].zero? }
|
|
116
125
|
propagate_bits(@follow_bits, dependencies, seeds)
|
|
117
126
|
end
|
|
118
127
|
|
|
128
|
+
# @rbs (Array[Array[Integer]] dependencies) -> Array[Array[Integer]]
|
|
129
|
+
def freeze_dependencies(dependencies)
|
|
130
|
+
dependencies.map { |targets| targets.uniq.sort.freeze }.freeze
|
|
131
|
+
end
|
|
132
|
+
|
|
119
133
|
# @rbs (IR::Production production, Array[Array[Integer]] dependencies) -> void
|
|
120
134
|
def initialize_follow(production, dependencies)
|
|
121
135
|
trailer = 0
|
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
require "json"
|
|
5
|
+
require "optparse"
|
|
6
|
+
require_relative "../diff"
|
|
7
|
+
require_relative "../impact"
|
|
8
|
+
|
|
9
|
+
module Ibex
|
|
10
|
+
# CLI adapter for potential and confirmed grammar impact reports.
|
|
11
|
+
# rubocop:disable Metrics/ModuleLength, Metrics/MethodLength, Metrics/AbcSize
|
|
12
|
+
module CLIImpact
|
|
13
|
+
include CLIAnalysis
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
# @rbs (Array[String] arguments) -> Integer
|
|
18
|
+
def run_impact_command(arguments)
|
|
19
|
+
settings = impact_option_parser(arguments)
|
|
20
|
+
if settings[:help]
|
|
21
|
+
@stdout.puts(settings.fetch(:help))
|
|
22
|
+
return 0
|
|
23
|
+
end
|
|
24
|
+
paths = settings.fetch(:paths)
|
|
25
|
+
unless [1, 2].include?(paths.length)
|
|
26
|
+
raise Ibex::Error, "(impact):1:1: impact requires one grammar or two grammar files"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
extend CLIAnalysis unless singleton_class.ancestors.include?(CLIAnalysis)
|
|
30
|
+
settings[:algorithm] = local_configuration_value(settings, "parser.algorithm")
|
|
31
|
+
analysis = if paths.length == 1
|
|
32
|
+
potential_impact(paths, settings)
|
|
33
|
+
else
|
|
34
|
+
confirmed_impact(paths, settings)
|
|
35
|
+
end
|
|
36
|
+
report, identities, gate_report = analysis
|
|
37
|
+
apply_baseline(report, identities, settings)
|
|
38
|
+
apply_baseline(gate_report, identities, settings)
|
|
39
|
+
write_impact_report(report, settings.fetch(:format))
|
|
40
|
+
Impact::Severity.fails?(gate_report, settings.fetch(:fail_on)) ? 1 : 0
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# @rbs (Array[String]) -> Hash[Symbol, untyped]
|
|
44
|
+
def impact_option_parser(arguments)
|
|
45
|
+
settings = {
|
|
46
|
+
paths: [], symbols: [], depth: nil, kinds: [:all], severity: "medium", coverage: [],
|
|
47
|
+
fail_on: [], format: "json", update_baseline: false, configuration_explicit: [],
|
|
48
|
+
algorithm: Configuration::Registry.fetch("parser.algorithm").default,
|
|
49
|
+
mode: Configuration::Registry.fetch("grammar.mode").default
|
|
50
|
+
} #: Hash[Symbol, untyped]
|
|
51
|
+
# rubocop:disable Metrics/BlockLength
|
|
52
|
+
parser = OptionParser.new do |options|
|
|
53
|
+
options.banner = "Usage: ibex impact [options] GRAMMAR or OLD NEW"
|
|
54
|
+
options.on("--symbol=NAME[,NAME]", "seed nonterminals for a one-version analysis") do |value|
|
|
55
|
+
settings[:symbols].concat(value.split(",").map(&:strip))
|
|
56
|
+
end
|
|
57
|
+
options.on("--depth=N", Integer, "maximum propagation depth") do |value|
|
|
58
|
+
raise OptionParser::InvalidArgument, "--depth must be non-negative" if value.negative?
|
|
59
|
+
|
|
60
|
+
settings[:depth] = value
|
|
61
|
+
end
|
|
62
|
+
options.on("--kind=LIST", "reference, first, or follow") { |value| settings[:kinds] = parse_kinds(value) }
|
|
63
|
+
options.on("--severity=LEVEL", Impact::Severity::LEVELS, "minimum severity") do |value|
|
|
64
|
+
settings[:severity] = value
|
|
65
|
+
end
|
|
66
|
+
options.on("--coverage=PATH", "runtime coverage report (repeatable)") { |value| settings[:coverage] << value }
|
|
67
|
+
options.on("--baseline=PATH", "known conflict baseline") { |value| settings[:baseline] = value }
|
|
68
|
+
options.on("--update-baseline", "write the current conflict baseline") { settings[:update_baseline] = true }
|
|
69
|
+
options.on("--fail-on=LIST", "CI gate conditions") do |value|
|
|
70
|
+
settings[:fail_on] = Impact::Severity.validate_gates(value.split(",").map(&:strip))
|
|
71
|
+
end
|
|
72
|
+
options.on("--algorithm=NAME", %w[slr lalr ielr lr1], "algorithm for grammar inputs") do |value|
|
|
73
|
+
set_local_configuration_option(settings, :algorithm, value.to_sym)
|
|
74
|
+
end
|
|
75
|
+
options.on("--mode=MODE", %w[default extended], "grammar mode") do |value|
|
|
76
|
+
set_local_configuration_option(settings, :mode, value.to_sym)
|
|
77
|
+
set_configuration_option(:mode, value.to_sym)
|
|
78
|
+
end
|
|
79
|
+
options.on("--format=FORMAT", %w[json text], "json or text") { |value| settings[:format] = value }
|
|
80
|
+
options.on("--help", "show help") { settings[:help] = options.to_s }
|
|
81
|
+
end
|
|
82
|
+
# rubocop:enable Metrics/BlockLength
|
|
83
|
+
settings[:paths] = parser.parse(arguments)
|
|
84
|
+
settings
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# @rbs (String value) -> Array[Symbol]
|
|
88
|
+
def parse_kinds(value)
|
|
89
|
+
kinds = value.split(",").map(&:strip).map(&:to_sym)
|
|
90
|
+
unknown = kinds - %i[reference first follow]
|
|
91
|
+
raise OptionParser::InvalidArgument, "unknown impact kind #{unknown.first.inspect}" unless unknown.empty?
|
|
92
|
+
|
|
93
|
+
kinds.uniq
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# @rbs (Array[String], Hash[Symbol, untyped]) -> [Hash[Symbol, Object?], Array[String], Hash[Symbol, Object?]]
|
|
97
|
+
def potential_impact(paths, settings)
|
|
98
|
+
automaton = load_analysis_automaton(paths.fetch(0), settings.fetch(:algorithm), explicit: algo_set?(settings))
|
|
99
|
+
names = settings.fetch(:symbols)
|
|
100
|
+
raise Ibex::Error, "(impact):1:1: one-version impact requires --symbol" if names.empty?
|
|
101
|
+
|
|
102
|
+
seeds = Impact::Seeds.new(automaton.grammar, names)
|
|
103
|
+
graph = Impact::Graph.new(automaton.grammar)
|
|
104
|
+
nodes, symbol_kinds = propagate(graph, seeds.ids, settings)
|
|
105
|
+
automaton_impact = Impact::AutomatonImpact.new(automaton, nodes.keys)
|
|
106
|
+
coverage = load_coverage(automaton, automaton_impact.production_ids, settings)
|
|
107
|
+
reporter = Impact::Report.new(
|
|
108
|
+
mode: "potential", algorithm: automaton.algorithm, grammar: automaton.grammar, before: nil, after: automaton,
|
|
109
|
+
seeds: seeds.records, nodes: nodes, symbol_kinds: symbol_kinds, set_changes: {},
|
|
110
|
+
automaton: potential_automaton_document(automaton, automaton_impact), actions: [], coverage: coverage.to_h,
|
|
111
|
+
minimum: settings.fetch(:severity), warnings: nullable_warnings(seeds, coverage)
|
|
112
|
+
)
|
|
113
|
+
[reporter.to_h, conflict_identities(automaton), reporter.to_h(minimum: "info")]
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# @rbs (Array[String], Hash[Symbol, untyped]) -> [Hash[Symbol, Object?], Array[String], Hash[Symbol, Object?]]
|
|
117
|
+
def confirmed_impact(paths, settings)
|
|
118
|
+
before = load_analysis_automaton(paths.fetch(0), settings.fetch(:algorithm), explicit: algo_set?(settings))
|
|
119
|
+
after = load_analysis_automaton(paths.fetch(1), settings.fetch(:algorithm), explicit: algo_set?(settings))
|
|
120
|
+
diff = Diff.new(before, after).to_h
|
|
121
|
+
rules = diff.fetch(:rules) #: Hash[Symbol, Array[Hash[Symbol, Object?]]]
|
|
122
|
+
names = rules.values_at(:added, :removed, :changed).flatten.map do |record|
|
|
123
|
+
record.fetch(:id).to_s
|
|
124
|
+
end.uniq.sort
|
|
125
|
+
seed_names = names.select { |name| after.grammar.symbol(name) }
|
|
126
|
+
seeds = Impact::Seeds.new(after.grammar, seed_names, origin: "diff")
|
|
127
|
+
removed_seed_names = names.reject { |name| after.grammar.symbol(name) }
|
|
128
|
+
removed_seeds = Impact::Seeds.new(before.grammar, removed_seed_names, origin: "diff")
|
|
129
|
+
seed_records = (seeds.records + removed_seeds.records).sort_by { |record| record.fetch(:symbol).to_s }
|
|
130
|
+
symbol_changes = diff.fetch(:symbols) #: Hash[Symbol, Array[Hash[Symbol, Object?]]]
|
|
131
|
+
metadata_names = symbol_changes.values.flatten.map { |record| record.fetch(:id).to_s }.uniq.sort
|
|
132
|
+
graph = Impact::Graph.new(after.grammar)
|
|
133
|
+
nodes, symbol_kinds = propagate(graph, seeds.ids, settings)
|
|
134
|
+
set_changes, changed_kinds = compare_sets(before, after)
|
|
135
|
+
symbol_kinds = merge_symbol_kinds(symbol_kinds, changed_kinds, after.grammar)
|
|
136
|
+
precedence_kinds = precedence_symbol_kinds(symbol_changes, after.grammar)
|
|
137
|
+
symbol_kinds = merge_symbol_kinds(symbol_kinds, precedence_kinds, after.grammar)
|
|
138
|
+
automaton_impact = Impact::AutomatonImpact.new(after, nodes.keys)
|
|
139
|
+
actions = Impact::ActionImpact.new(before.grammar, after.grammar, affected_names: names).to_a
|
|
140
|
+
coverage = load_coverage(after, automaton_impact.production_ids, settings)
|
|
141
|
+
reporter = Impact::Report.new(
|
|
142
|
+
mode: "diff", algorithm: after.algorithm, grammar: after.grammar, before: before, after: after,
|
|
143
|
+
seeds: seed_records, nodes: nodes, symbol_kinds: symbol_kinds, set_changes: set_changes,
|
|
144
|
+
metadata_names: metadata_names,
|
|
145
|
+
automaton: confirmed_automaton_document(before, after, diff, automaton_impact), actions: actions,
|
|
146
|
+
coverage: coverage.to_h, minimum: settings.fetch(:severity), warnings: coverage.warnings
|
|
147
|
+
)
|
|
148
|
+
[reporter.to_h, conflict_identities(after), reporter.to_h(minimum: "info")]
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# @rbs (Impact::Graph, Array[Integer], Hash[Symbol, untyped]) ->
|
|
152
|
+
# [Hash[Integer, Impact::Node], Hash[Integer, Array[String]]]
|
|
153
|
+
def propagate(graph, seed_ids, settings)
|
|
154
|
+
kinds = settings.fetch(:kinds) == [:all] ? %i[reference first follow] : settings.fetch(:kinds)
|
|
155
|
+
nodes = {} #: Hash[Integer, Impact::Node]
|
|
156
|
+
symbol_kinds = Hash.new { |hash, key| hash[key] = [] } #: Hash[Integer, Array[String]]
|
|
157
|
+
kinds.each do |kind|
|
|
158
|
+
current = Impact::Propagation.new(graph).propagate(seed_ids, kind, max_depth: settings[:depth])
|
|
159
|
+
current.each do |id, node|
|
|
160
|
+
best = nodes[id]
|
|
161
|
+
nodes[id] = node if best.nil? || node.distance < best.distance
|
|
162
|
+
symbol_kinds[id] << kind.to_s unless node.distance.zero? && node.witness.empty?
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
[nodes.sort.to_h, symbol_kinds.transform_values { |value| value.uniq.sort }]
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# @rbs (IR::Automaton, Array[Integer], Hash[Symbol, untyped]) -> Impact::CoverageImpact
|
|
169
|
+
def load_coverage(automaton, production_ids, settings)
|
|
170
|
+
reports = settings.fetch(:coverage).map { |path| [path, Coverage::Report.load_file(path)] }
|
|
171
|
+
Impact::CoverageImpact.new(automaton, production_ids, reports)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# @rbs (Impact::Seeds, Impact::CoverageImpact) -> Array[String]
|
|
175
|
+
def nullable_warnings(seeds, coverage)
|
|
176
|
+
warnings = coverage.warnings.dup
|
|
177
|
+
seeds.records.each do |seed|
|
|
178
|
+
next unless seed.fetch(:nullable_boundary)
|
|
179
|
+
|
|
180
|
+
warnings << "#{seed.fetch(:symbol)}: nullable boundary may make potential propagation " \
|
|
181
|
+
"incomplete; compare two versions"
|
|
182
|
+
end
|
|
183
|
+
warnings
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# @rbs (IR::Automaton, Impact::AutomatonImpact) -> Hash[Symbol, Object?]
|
|
187
|
+
def potential_automaton_document(automaton, impact)
|
|
188
|
+
added = conflict_identities(automaton).map { |id| { id: id, value: nil } } #: Array[Hash[Symbol, Object?]]
|
|
189
|
+
removed = [] #: Array[Hash[Symbol, Object?]]
|
|
190
|
+
changed = [] #: Array[Hash[Symbol, Object?]]
|
|
191
|
+
{ states: { before: nil, after: automaton.states.length, delta: nil },
|
|
192
|
+
affected_states: impact.affected_states,
|
|
193
|
+
conflicts: { added: added, removed: removed, changed: changed },
|
|
194
|
+
unreachable: unreachable_state_ids(automaton),
|
|
195
|
+
unreachable_nonterminals: unreachable_nonterminal_names(automaton.grammar) }
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# @rbs (IR::Automaton, IR::Automaton, Hash[Symbol, Object?], Impact::AutomatonImpact) -> Hash[Symbol, Object?]
|
|
199
|
+
def confirmed_automaton_document(before, after, diff, impact)
|
|
200
|
+
{
|
|
201
|
+
states: {
|
|
202
|
+
before: before.states.length, after: after.states.length, delta: after.states.length - before.states.length
|
|
203
|
+
},
|
|
204
|
+
affected_states: impact.affected_states, conflicts: diff.fetch(:conflicts),
|
|
205
|
+
unreachable: newly_unreachable_state_ids(before, after),
|
|
206
|
+
unreachable_nonterminals: newly_unreachable_nonterminal_names(before.grammar, after.grammar)
|
|
207
|
+
}
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# @rbs (IR::Grammar) -> Array[String]
|
|
211
|
+
def unreachable_nonterminal_names(grammar)
|
|
212
|
+
grammar.warnings.filter_map do |warning|
|
|
213
|
+
next unless warning.fetch(:type).to_sym == :unreachable_nonterminal
|
|
214
|
+
|
|
215
|
+
warning.fetch(:symbol).to_s
|
|
216
|
+
end.uniq.sort
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# @rbs (IR::Grammar, IR::Grammar) -> Array[String]
|
|
220
|
+
def newly_unreachable_nonterminal_names(before, after)
|
|
221
|
+
unreachable_nonterminal_names(after) - unreachable_nonterminal_names(before)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# @rbs (IR::Automaton) -> Array[Integer]
|
|
225
|
+
def unreachable_state_ids(automaton)
|
|
226
|
+
reachable = LALR::UnreachableStates.reachable_states(automaton.states, automaton.entry_states.values)
|
|
227
|
+
(0...automaton.states.length).to_a - reachable
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# @rbs (IR::Automaton, IR::Automaton) -> Array[Integer]
|
|
231
|
+
def newly_unreachable_state_ids(before, after)
|
|
232
|
+
before_keys = unreachable_state_keys(before)
|
|
233
|
+
unreachable_state_ids(after).reject { |id| before_keys.include?(unreachable_state_key(after, id)) }
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# @rbs (IR::Automaton) -> Array[String]
|
|
237
|
+
def unreachable_state_keys(automaton)
|
|
238
|
+
unreachable_state_ids(automaton).map { |id| unreachable_state_key(automaton, id) }
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# @rbs (IR::Automaton, Integer) -> String
|
|
242
|
+
def unreachable_state_key(automaton, id)
|
|
243
|
+
state = automaton.states.fetch(id)
|
|
244
|
+
items = state.items.map do |item|
|
|
245
|
+
production_name = if item.production == LALR::Builder::AUGMENTED_PRODUCTION
|
|
246
|
+
"$accept"
|
|
247
|
+
else
|
|
248
|
+
production = automaton.grammar.productions.fetch(item.production)
|
|
249
|
+
production_shape(automaton.grammar, production.id)
|
|
250
|
+
end
|
|
251
|
+
[production_name, item.dot,
|
|
252
|
+
item.lookaheads.map { |lookahead| automaton.grammar.symbol_by_id(lookahead)&.name || lookahead.to_s }]
|
|
253
|
+
end.sort_by(&:inspect)
|
|
254
|
+
JSON.generate(items)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# @rbs (IR::Automaton, IR::Automaton) ->
|
|
258
|
+
# [Hash[String, Hash[Symbol, Object?]], Hash[String, Array[String]]]
|
|
259
|
+
def compare_sets(before, after)
|
|
260
|
+
before_sets = Analysis::Sets.new(before.grammar)
|
|
261
|
+
after_sets = Analysis::Sets.new(after.grammar)
|
|
262
|
+
names = (before.grammar.nonterminals.map(&:name) + after.grammar.nonterminals.map(&:name)).uniq.sort
|
|
263
|
+
changes = {} #: Hash[String, Hash[Symbol, Object?]]
|
|
264
|
+
kinds = {} #: Hash[String, Array[String]]
|
|
265
|
+
names.each do |name|
|
|
266
|
+
before_symbol = before.grammar.symbol(name)
|
|
267
|
+
after_symbol = after.grammar.symbol(name)
|
|
268
|
+
first = set_change(before_sets, after_sets, before_symbol, after_symbol, :first)
|
|
269
|
+
follow = set_change(before_sets, after_sets, before_symbol, after_symbol, :follow)
|
|
270
|
+
nullable = nullable_change(before_sets, after_sets, before_symbol, after_symbol)
|
|
271
|
+
next if set_change_empty?(first, follow, nullable)
|
|
272
|
+
|
|
273
|
+
changes[name] = { first: first, follow: follow, nullable: nullable }
|
|
274
|
+
kinds[name] = []
|
|
275
|
+
kinds[name] << "first" unless first[:added].empty? && first[:removed].empty?
|
|
276
|
+
kinds[name] << "follow" unless follow[:added].empty? && follow[:removed].empty?
|
|
277
|
+
kinds[name] << "nullable" if nullable
|
|
278
|
+
end
|
|
279
|
+
[changes, kinds]
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# @rbs (Analysis::Sets, Analysis::Sets, IR::GrammarSymbol?, IR::GrammarSymbol?, Symbol) ->
|
|
283
|
+
# Hash[Symbol, Array[String]]
|
|
284
|
+
def set_change(before, after, before_symbol, after_symbol, kind)
|
|
285
|
+
before_values = before_symbol&.nonterminal? ? before.public_send(kind, before_symbol) : [] #: Array[String]
|
|
286
|
+
after_values = after_symbol&.nonterminal? ? after.public_send(kind, after_symbol) : [] #: Array[String]
|
|
287
|
+
{ added: after_values - before_values, removed: before_values - after_values }
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# @rbs (Hash[Symbol, Array[String]], Hash[Symbol, Array[String]], Hash[Symbol, Object?]?) -> bool
|
|
291
|
+
def set_change_empty?(first, follow, nullable)
|
|
292
|
+
first_empty = first[:added].empty? && first[:removed].empty?
|
|
293
|
+
follow_empty = follow[:added].empty? && follow[:removed].empty?
|
|
294
|
+
first_empty && follow_empty && nullable.nil?
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# @rbs (Analysis::Sets, Analysis::Sets, IR::GrammarSymbol?, IR::GrammarSymbol?) -> Hash[Symbol, Object?]?
|
|
298
|
+
def nullable_change(before, after, before_symbol, after_symbol)
|
|
299
|
+
return nil unless before_symbol || after_symbol
|
|
300
|
+
|
|
301
|
+
old = before_symbol ? before.nullable?(before_symbol) : false
|
|
302
|
+
current = after_symbol ? after.nullable?(after_symbol) : false
|
|
303
|
+
return nil if old == current
|
|
304
|
+
|
|
305
|
+
{ before: old, after: current }
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# @rbs (Hash[Integer, Array[String]], Hash[String, Array[String]], IR::Grammar) -> Hash[Integer, Array[String]]
|
|
309
|
+
def merge_symbol_kinds(symbol_kinds, changed_kinds, grammar)
|
|
310
|
+
result = symbol_kinds.transform_values(&:dup)
|
|
311
|
+
changed_kinds.each do |name, kinds|
|
|
312
|
+
id = grammar.symbol(name)&.id
|
|
313
|
+
next unless id
|
|
314
|
+
|
|
315
|
+
result[id] = (result.fetch(id, []) + kinds).uniq.sort
|
|
316
|
+
end
|
|
317
|
+
result
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# @rbs (Hash[Symbol, Array[Hash[Symbol, Object?]]], IR::Grammar) -> Hash[String, Array[String]]
|
|
321
|
+
def precedence_symbol_kinds(symbol_changes, grammar)
|
|
322
|
+
result = {} #: Hash[String, Array[String]]
|
|
323
|
+
symbol_changes.fetch(:changed).each do |record|
|
|
324
|
+
before = record.fetch(:before)
|
|
325
|
+
after = record.fetch(:after)
|
|
326
|
+
next unless before.is_a?(Hash) && after.is_a?(Hash)
|
|
327
|
+
next if before.fetch(:precedence) == after.fetch(:precedence)
|
|
328
|
+
|
|
329
|
+
name = record.fetch(:id).to_s
|
|
330
|
+
result[name] = ["precedence"] if grammar.symbol(name)
|
|
331
|
+
end
|
|
332
|
+
result
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# @rbs (IR::Automaton) -> Array[String]
|
|
336
|
+
def conflict_identities(automaton)
|
|
337
|
+
automaton.states.flat_map do |state|
|
|
338
|
+
state.conflicts.map { |conflict| conflict_identity(automaton.grammar, conflict) }
|
|
339
|
+
end.uniq.sort
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# @rbs (IR::Grammar, Hash[Symbol, Object?]) -> String
|
|
343
|
+
def conflict_identity(grammar, conflict)
|
|
344
|
+
type = conflict.fetch(:type).to_s.to_sym
|
|
345
|
+
symbol = conflict.fetch(:symbol).to_s
|
|
346
|
+
if type == :shift_reduce
|
|
347
|
+
reduce = conflict.fetch(:reduce) #: Integer
|
|
348
|
+
"shift_reduce:#{symbol}:#{production_shape(grammar, reduce)}"
|
|
349
|
+
else
|
|
350
|
+
reduction_ids = conflict.fetch(:reductions) #: Array[Integer]
|
|
351
|
+
reductions = reduction_ids.map { |id| production_shape(grammar, id) }.sort
|
|
352
|
+
"reduce_reduce:#{symbol}:#{reductions.join('|')}"
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
# @rbs (IR::Grammar, Integer) -> String
|
|
357
|
+
def production_shape(grammar, id)
|
|
358
|
+
production = grammar.productions.fetch(id)
|
|
359
|
+
lhs = grammar.symbol_by_id(production.lhs)&.name || production.lhs.to_s
|
|
360
|
+
rhs = production.rhs.map { |symbol_id| grammar.symbol_by_id(symbol_id)&.name || symbol_id.to_s }
|
|
361
|
+
"#{lhs}->#{rhs.join(' ')}"
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
# @rbs (Hash[Symbol, Object?], Array[String], Hash[Symbol, untyped]) -> void
|
|
365
|
+
def apply_baseline(report, identities, settings)
|
|
366
|
+
path = settings[:baseline]
|
|
367
|
+
baseline = Impact::Baseline.new(path) if path
|
|
368
|
+
baseline.write(identities) if baseline && settings[:update_baseline]
|
|
369
|
+
return unless baseline
|
|
370
|
+
|
|
371
|
+
known = baseline.conflicts
|
|
372
|
+
conflicts = report.dig(:automaton, :conflicts, :added)
|
|
373
|
+
return unless conflicts
|
|
374
|
+
|
|
375
|
+
original_count = conflicts.length
|
|
376
|
+
conflicts.reject! { |entry| known.include?(entry.fetch(:id)) }
|
|
377
|
+
totals = report.fetch(:totals) #: Hash[Symbol, Integer]
|
|
378
|
+
totals[:critical] -= original_count - conflicts.length
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
# @rbs (Hash[Symbol, Object?], String) -> void
|
|
382
|
+
def write_impact_report(report, format)
|
|
383
|
+
return @stdout.puts(JSON.pretty_generate(report)) if format == "json"
|
|
384
|
+
|
|
385
|
+
symbols = report.fetch(:symbols) #: Array[untyped]
|
|
386
|
+
warnings = report.fetch(:warnings) #: Array[String]
|
|
387
|
+
@stdout.puts("impact mode=#{report.fetch(:mode)} symbols=#{symbols.length} " \
|
|
388
|
+
"affected_states=#{report.dig(:automaton, :affected_states).length}")
|
|
389
|
+
warnings.each { |warning| @stdout.puts("warning: #{warning}") }
|
|
390
|
+
end
|
|
391
|
+
end
|
|
392
|
+
# rubocop:enable Metrics/ModuleLength, Metrics/MethodLength, Metrics/AbcSize
|
|
393
|
+
end
|
data/lib/ibex/cli.rb
CHANGED
|
@@ -25,6 +25,8 @@ require_relative "lalr/inadequacy_report"
|
|
|
25
25
|
require_relative "lalr/direct_lookaheads"
|
|
26
26
|
require_relative "lalr/ielr_partition"
|
|
27
27
|
require_relative "lalr/builder"
|
|
28
|
+
require_relative "lalr/unreachable_states"
|
|
29
|
+
require_relative "impact"
|
|
28
30
|
require_relative "codegen/ruby"
|
|
29
31
|
require_relative "cli/counterexample_options"
|
|
30
32
|
require_relative "cli/generation_error_messages"
|
|
@@ -35,6 +37,7 @@ module Ibex
|
|
|
35
37
|
CLI_FEATURE_ROOT = File.expand_path("cli", __dir__ || raise("CLI source directory is unavailable")) #: String
|
|
36
38
|
autoload :CLIAmbiguity, File.join(CLI_FEATURE_ROOT, "ambiguity")
|
|
37
39
|
autoload :CLIAnalysis, File.join(CLI_FEATURE_ROOT, "analysis")
|
|
40
|
+
autoload :CLIImpact, File.join(CLI_FEATURE_ROOT, "impact")
|
|
38
41
|
autoload :CLIBisonImport, File.join(CLI_FEATURE_ROOT, "bison_import")
|
|
39
42
|
autoload :CLICoverage, File.join(CLI_FEATURE_ROOT, "coverage")
|
|
40
43
|
autoload :CLIDebug, File.join(CLI_FEATURE_ROOT, "debug")
|
|
@@ -152,6 +155,7 @@ module Ibex
|
|
|
152
155
|
SUBCOMMAND_HANDLERS = {
|
|
153
156
|
"check" => %i[CLIAmbiguity run_check_command],
|
|
154
157
|
"diff" => %i[CLIAnalysis run_diff_command],
|
|
158
|
+
"impact" => %i[CLIImpact run_impact_command],
|
|
155
159
|
"diagnose" => %i[CLIDiagnostics run_diagnose_command],
|
|
156
160
|
"coverage" => %i[CLICoverage run_coverage_command], "config" => %i[CLIConfig run_config_command],
|
|
157
161
|
"debug" => %i[CLIDebug run_debug_command],
|
|
@@ -374,6 +378,7 @@ module Ibex
|
|
|
374
378
|
options.separator(" debug AUTOMATON [TOKEN] simulate validated Automaton IR tables")
|
|
375
379
|
options.separator(" diagnose collect frontend diagnostics")
|
|
376
380
|
options.separator(" diff OLD NEW classify grammar and automaton changes")
|
|
381
|
+
options.separator(" impact GRAMMAR report grammar change propagation")
|
|
377
382
|
options.separator(" doc render grammar documentation")
|
|
378
383
|
options.separator(" errors --list|--update list or update example-keyed syntax error messages")
|
|
379
384
|
options.separator(" equiv LEFT RIGHT search for bounded language differences")
|
data/lib/ibex/diff.rb
CHANGED
|
@@ -45,7 +45,8 @@ module Ibex
|
|
|
45
45
|
symbol.name,
|
|
46
46
|
{
|
|
47
47
|
kind: symbol.kind, reserved: symbol.reserved, display_name: symbol.display_name,
|
|
48
|
-
semantic_type: symbol.semantic_type, precedence: symbol.precedence
|
|
48
|
+
semantic_type: symbol.semantic_type, precedence: symbol.precedence,
|
|
49
|
+
documentation: symbol.documentation
|
|
49
50
|
}
|
|
50
51
|
]
|
|
51
52
|
end
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module Ibex
|
|
5
|
+
module Impact
|
|
6
|
+
# Checks only structured action metadata; action source remains opaque.
|
|
7
|
+
class ActionImpact
|
|
8
|
+
# @rbs @before: IR::Grammar
|
|
9
|
+
# @rbs @after: IR::Grammar
|
|
10
|
+
# @rbs @affected_names: Array[String]?
|
|
11
|
+
attr_reader :findings #: Array[Hash[Symbol, Object?]]
|
|
12
|
+
|
|
13
|
+
# @rbs (IR::Grammar before, IR::Grammar after, ?affected_names: Array[String]?) -> void
|
|
14
|
+
def initialize(before, after, affected_names: nil)
|
|
15
|
+
@before = before
|
|
16
|
+
@after = after
|
|
17
|
+
@affected_names = affected_names
|
|
18
|
+
@findings = compare
|
|
19
|
+
freeze
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @rbs () -> Array[Hash[Symbol, Object?]]
|
|
23
|
+
def to_a
|
|
24
|
+
@findings
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
# @rbs () -> Array[Hash[Symbol, Object?]]
|
|
30
|
+
def compare
|
|
31
|
+
names = @after.nonterminals.map(&:name)
|
|
32
|
+
names &= @affected_names if @affected_names
|
|
33
|
+
names.sort.flat_map { |name| compare_rule(name) }.sort_by { |finding| finding.fetch(:production) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @rbs (String name) -> Array[Hash[Symbol, Object?]]
|
|
37
|
+
def compare_rule(name)
|
|
38
|
+
before = productions_for(@before, name)
|
|
39
|
+
after = productions_for(@after, name)
|
|
40
|
+
pair_productions(before, after).filter_map do |previous, production|
|
|
41
|
+
next if previous.rhs.length == production.rhs.length
|
|
42
|
+
|
|
43
|
+
finding_for(previous, production)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @rbs (Array[IR::Production] before, Array[IR::Production] after) -> Array[[IR::Production, IR::Production]]
|
|
48
|
+
def pair_productions(before, after)
|
|
49
|
+
unmatched = before.dup
|
|
50
|
+
pairs = pair_exact_productions(unmatched, after)
|
|
51
|
+
pair_by_location(unmatched, after, pairs)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# @rbs (Array[IR::Production] unmatched, Array[IR::Production] after) -> Array[[IR::Production, IR::Production]]
|
|
55
|
+
def pair_exact_productions(unmatched, after)
|
|
56
|
+
pairs = [] #: Array[[IR::Production, IR::Production]]
|
|
57
|
+
|
|
58
|
+
after.each do |production|
|
|
59
|
+
index = unmatched.index do |candidate|
|
|
60
|
+
production_signature(@before, candidate) == production_signature(@after, production)
|
|
61
|
+
end
|
|
62
|
+
next unless index
|
|
63
|
+
|
|
64
|
+
pairs << [unmatched.delete_at(index), production]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
pairs
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# @rbs (Array[IR::Production] unmatched, Array[IR::Production] after,
|
|
71
|
+
# Array[[IR::Production, IR::Production]]) -> Array[[IR::Production, IR::Production]]
|
|
72
|
+
def pair_by_location(unmatched, after, pairs)
|
|
73
|
+
after.each do |production|
|
|
74
|
+
next if pairs.any? { |_, candidate| candidate.equal?(production) }
|
|
75
|
+
|
|
76
|
+
index = unmatched.index do |candidate|
|
|
77
|
+
location = action_location_identity(production)
|
|
78
|
+
location && location == action_location_identity(candidate)
|
|
79
|
+
end
|
|
80
|
+
next unless index
|
|
81
|
+
|
|
82
|
+
pairs << [unmatched.delete_at(index), production]
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
pairs
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# @rbs (IR::Grammar grammar, String name) -> Array[IR::Production]
|
|
89
|
+
def productions_for(grammar, name)
|
|
90
|
+
lhs = grammar.symbol(name)&.id
|
|
91
|
+
return [] unless lhs
|
|
92
|
+
|
|
93
|
+
grammar.productions.select { |production| production.lhs == lhs }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# @rbs (IR::Grammar grammar, IR::Production production) -> [Array[String], String?]
|
|
97
|
+
def production_signature(grammar, production)
|
|
98
|
+
rhs = production.rhs.map { |id| grammar.symbol_by_id(id)&.name || id.to_s }
|
|
99
|
+
precedence = grammar.symbol_by_id(production.precedence_override)&.name
|
|
100
|
+
[rhs, precedence]
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# @rbs (IR::Production production) -> [Integer, Integer]?
|
|
104
|
+
def action_location_identity(production)
|
|
105
|
+
location = production.origin[:loc] || production.action&.location #: IR::location?
|
|
106
|
+
return unless location
|
|
107
|
+
|
|
108
|
+
line = location[:line]
|
|
109
|
+
column = location[:column]
|
|
110
|
+
return unless line && column
|
|
111
|
+
|
|
112
|
+
[line, column]
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# @rbs (IR::Production before, IR::Production after) -> Hash[Symbol, Object?]
|
|
116
|
+
def finding_for(before, after)
|
|
117
|
+
action = after.action
|
|
118
|
+
reason, severity = finding_reason(action, before.rhs.length, after.rhs.length)
|
|
119
|
+
{
|
|
120
|
+
production: production_name(@after, after), severity: severity,
|
|
121
|
+
reason: reason,
|
|
122
|
+
context_length: { before: before.rhs.length, after: after.rhs.length },
|
|
123
|
+
loc: after.action&.location || after.origin[:loc]
|
|
124
|
+
}
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# @rbs (IR::Action?, Integer, Integer) -> [String, String]
|
|
128
|
+
def finding_reason(action, before_length, after_length)
|
|
129
|
+
return %w[named_ref_index_out_of_range high] if out_of_range?(action, after_length)
|
|
130
|
+
return %w[context_length_stale high] if action&.context_length == before_length
|
|
131
|
+
|
|
132
|
+
%w[rhs_length_changed medium]
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# @rbs (IR::Action?, Integer) -> bool
|
|
136
|
+
def out_of_range?(action, length)
|
|
137
|
+
action&.named_refs&.any? { |reference| reference.fetch(:index) >= length } || false
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# @rbs (IR::Grammar grammar, IR::Production production) -> String
|
|
141
|
+
def production_name(grammar, production)
|
|
142
|
+
lhs = grammar.symbol_by_id(production.lhs)&.name || production.lhs.to_s
|
|
143
|
+
rhs = production.rhs.map { |id| grammar.symbol_by_id(id)&.name || id.to_s }
|
|
144
|
+
"#{lhs} -> #{rhs.join(' ')}"
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|