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