branchproof 0.7.0 → 0.9.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.
Files changed (75) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +53 -0
  3. data/README.md +255 -21
  4. data/doc/Branchproof/Analyzer.md +2 -2
  5. data/doc/Branchproof/CLI.md +4 -0
  6. data/doc/Branchproof/ComparisonReport.md +4 -0
  7. data/doc/Branchproof/Constraints/Solver.md +21 -0
  8. data/doc/Branchproof/Constraints.md +98 -0
  9. data/doc/Branchproof/CoverageIndex.md +3 -0
  10. data/doc/Branchproof/DecisionSyntax.md +6 -4
  11. data/doc/Branchproof/DecisionTable.md +153 -0
  12. data/doc/Branchproof/DefaultInstrumentation.md +13 -0
  13. data/doc/Branchproof/DefaultRuntime.md +11 -0
  14. data/doc/Branchproof/DefaultSyntax.md +7 -0
  15. data/doc/Branchproof/ExceptionInstrumentation.md +9 -0
  16. data/doc/Branchproof/ExceptionRuntime.md +27 -0
  17. data/doc/Branchproof/ExceptionSyntax.md +9 -0
  18. data/doc/Branchproof/ExtendedAlternativeRuntime.md +19 -0
  19. data/doc/Branchproof/FocusedReport.md +5 -2
  20. data/doc/Branchproof/Instrumenter.md +1 -1
  21. data/doc/Branchproof/IterationInstrumentation.md +7 -0
  22. data/doc/Branchproof/IterationRuntime.md +21 -0
  23. data/doc/Branchproof/IterationSyntax.md +11 -0
  24. data/doc/Branchproof/Loader.md +0 -4
  25. data/doc/Branchproof/MinitestAdapter.md +0 -3
  26. data/doc/Branchproof/Report.md +18 -0
  27. data/doc/Branchproof/Runtime.md +60 -1
  28. data/doc/Branchproof/SavedReport.md +15 -0
  29. data/doc/Branchproof/Source.md +7 -1
  30. data/doc/Branchproof/ValueInstrumentation.md +7 -0
  31. data/doc/Branchproof/ValueRuntime.md +26 -0
  32. data/doc/Branchproof/ValueSyntax.md +26 -0
  33. data/doc/Branchproof.md +20 -3
  34. data/doc/CHANGELOG.md +53 -0
  35. data/doc/README.md +255 -21
  36. data/lib/branchproof/analyzer.rb +80 -44
  37. data/lib/branchproof/cli.rb +28 -19
  38. data/lib/branchproof/comparison.rb +207 -19
  39. data/lib/branchproof/comparison_report.rb +49 -1
  40. data/lib/branchproof/constraints.rb +363 -0
  41. data/lib/branchproof/coverage_index.rb +30 -1
  42. data/lib/branchproof/decision_syntax.rb +50 -24
  43. data/lib/branchproof/decision_table.rb +377 -0
  44. data/lib/branchproof/default_instrumentation.rb +140 -0
  45. data/lib/branchproof/default_runtime.rb +16 -0
  46. data/lib/branchproof/default_syntax.rb +94 -0
  47. data/lib/branchproof/evidence.rb +133 -31
  48. data/lib/branchproof/exception_instrumentation.rb +96 -0
  49. data/lib/branchproof/exception_runtime.rb +41 -0
  50. data/lib/branchproof/exception_syntax.rb +157 -0
  51. data/lib/branchproof/extended_alternative_runtime.rb +23 -0
  52. data/lib/branchproof/flow_instrumentation.rb +52 -23
  53. data/lib/branchproof/focused_report.rb +77 -9
  54. data/lib/branchproof/instrumenter.rb +93 -37
  55. data/lib/branchproof/iteration_instrumentation.rb +66 -0
  56. data/lib/branchproof/iteration_runtime.rb +79 -0
  57. data/lib/branchproof/iteration_syntax.rb +75 -0
  58. data/lib/branchproof/limits.rb +4 -1
  59. data/lib/branchproof/loader.rb +24 -7
  60. data/lib/branchproof/minimizer.rb +18 -13
  61. data/lib/branchproof/minitest_adapter.rb +11 -16
  62. data/lib/branchproof/records.rb +2 -0
  63. data/lib/branchproof/report.rb +301 -68
  64. data/lib/branchproof/runtime.rb +32 -30
  65. data/lib/branchproof/saved_report.rb +200 -14
  66. data/lib/branchproof/source.rb +140 -71
  67. data/lib/branchproof/value_instrumentation.rb +35 -0
  68. data/lib/branchproof/value_runtime.rb +73 -0
  69. data/lib/branchproof/value_syntax.rb +116 -0
  70. data/lib/branchproof/version.rb +1 -1
  71. data/lib/branchproof/worker.rb +1 -4
  72. data/lib/branchproof.rb +2 -0
  73. data/llms.txt +20 -3
  74. data/sig/branchproof.rbs +64 -1
  75. metadata +32 -1
@@ -1,44 +1,65 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Keep each bounded source rewrite together so its evaluation order can be audited.
4
- # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
4
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/ModuleLength
5
5
 
6
6
  module Branchproof
7
7
  # Source-location edits around Ruby's native matching and assignment operations.
8
8
  module FlowInstrumentation
9
9
  private
10
10
 
11
- def render_flow(bytes, decision, nested)
11
+ def render_flow(bytes, decision, nested, encloses)
12
12
  metadata = decision.fetch(:instrumentation)
13
13
  case metadata.fetch(:type)
14
14
  when "safe_navigation"
15
15
  receiver = metadata.fetch(:receiver)
16
- replacements = [flow_replacement(bytes, receiver, nested) do |expression|
16
+ replacements = [flow_replacement(bytes, receiver, nested, encloses) do |expression|
17
17
  "#{self.class::RUNTIME}.flow_receiver(#{decision[:id].inspect}, (#{expression}))"
18
18
  end]
19
- flow_fragments(bytes, decision, nested, replacements)
19
+ flow_fragments(bytes, decision, nested, replacements, encloses)
20
20
  when "assignment"
21
21
  rhs = metadata.fetch(:rhs)
22
- replacements = [flow_replacement(bytes, rhs, nested) do |expression|
23
- "(begin; #{self.class::RUNTIME}.flow_path(#{decision[:id].inspect}, 1); (#{expression}); end)"
24
- end]
25
- expression = flow_fragments(bytes, decision, nested, replacements)
26
- flow_frame(decision[:id], expression, default_path: 0)
22
+ identifier = decision[:id].inspect
23
+ replacements = []
24
+ if metadata[:receiver]
25
+ receiver = metadata.fetch(:receiver)
26
+ replacements << flow_replacement(bytes, receiver, nested, encloses) do |expression|
27
+ "#{self.class::RUNTIME}.flow_assignment_receiver(#{identifier}, (#{expression}))"
28
+ end
29
+ replacements << flow_replacement(bytes, rhs, nested, encloses) do |expression|
30
+ "(begin; #{self.class::RUNTIME}.flow_assignment_path(#{identifier}, 2); (#{expression}); end)"
31
+ end
32
+ else
33
+ replacements << flow_replacement(bytes, rhs, nested, encloses) do |expression|
34
+ "(begin; #{self.class::RUNTIME}.flow_path(#{identifier}, 1); (#{expression}); end)"
35
+ end
36
+ end
37
+ expression = flow_fragments(bytes, decision, nested, replacements, encloses)
38
+ if metadata[:receiver]
39
+ flow_assignment_frame(decision[:id], expression, default_path: 1)
40
+ else
41
+ flow_frame(decision[:id], expression, default_path: 0)
42
+ end
27
43
  when "case"
28
- render_case_flow(bytes, decision, nested, metadata)
44
+ render_case_flow(bytes, decision, nested, metadata, encloses)
29
45
  when "case_match"
30
- render_pattern_flow(bytes, decision, nested, metadata)
46
+ render_pattern_flow(bytes, decision, nested, metadata, encloses)
31
47
  else
32
48
  raise ArgumentError, "unknown instrumentation type: #{metadata[:type]}"
33
49
  end
34
50
  end
35
51
 
36
- def render_case_flow(bytes, decision, nested, metadata)
52
+ def render_case_flow(bytes, decision, nested, metadata, encloses)
37
53
  identifier = decision[:id].inspect
38
54
  runtime = self.class::RUNTIME
39
55
  replacements = metadata.fetch(:candidates).map do |candidate|
40
- flow_replacement(bytes, candidate, nested) do |expression|
41
- "(begin; #{runtime}.flow_candidate(#{identifier}, #{candidate[:index]}); (#{expression}); end)"
56
+ flow_replacement(bytes, candidate, nested, encloses) do |expression|
57
+ if candidate[:splat]
58
+ splat_expression = expression.sub(/\A\*/, "")
59
+ "*(begin; #{runtime}.flow_candidate(#{identifier}, #{candidate[:index]}); (#{splat_expression}); end)"
60
+ else
61
+ "(begin; #{runtime}.flow_candidate(#{identifier}, #{candidate[:index]}); (#{expression}); end)"
62
+ end
42
63
  end
43
64
  end
44
65
  metadata.fetch(:branches).each do |branch|
@@ -55,10 +76,10 @@ module Branchproof
55
76
  replacements << { start: metadata.fetch(:end_start), length: 0,
56
77
  text: "else; #{runtime}.flow_select(#{identifier}, #{index}); nil; " }
57
78
  end
58
- flow_frame(decision[:id], flow_fragments(bytes, decision, nested, replacements))
79
+ flow_frame(decision[:id], flow_fragments(bytes, decision, nested, replacements, encloses))
59
80
  end
60
81
 
61
- def render_pattern_flow(bytes, decision, nested, metadata)
82
+ def render_pattern_flow(bytes, decision, nested, metadata, encloses)
62
83
  identifier = decision[:id].inspect
63
84
  runtime = self.class::RUNTIME
64
85
  replacements = metadata.fetch(:branches).map do |branch|
@@ -71,27 +92,27 @@ module Branchproof
71
92
  replacements << { start: alternative[:insert_at], length: 0,
72
93
  text: "; #{runtime}.flow_select(#{identifier}, #{alternative[:index]}); " }
73
94
  end
74
- flow_frame(decision[:id], flow_fragments(bytes, decision, nested, replacements))
95
+ flow_frame(decision[:id], flow_fragments(bytes, decision, nested, replacements, encloses))
75
96
  end
76
97
 
77
- def flow_replacement(bytes, location, nested)
98
+ def flow_replacement(bytes, location, nested, encloses)
78
99
  start = location.fetch(:byte_start)
79
100
  length = location.fetch(:byte_length)
80
- { start: start, length: length, text: yield(render_children(bytes, start, length, nested)) }
101
+ { start: start, length: length, text: yield(render_children(bytes, start, length, nested, encloses)) }
81
102
  end
82
103
 
83
- def flow_fragments(bytes, decision, nested, replacements)
104
+ def flow_fragments(bytes, decision, nested, replacements, encloses)
84
105
  cursor = decision.fetch(:byte_start)
85
106
  finish = cursor + decision.fetch(:byte_length)
86
107
  chunks = []
87
108
  replacements.sort_by { |edit| [edit[:start], edit[:length]] }.each do |edit|
88
109
  raise ArgumentError, "overlapping flow edits" if edit[:start] < cursor
89
110
 
90
- chunks << render_children(bytes, cursor, edit[:start] - cursor, nested)
111
+ chunks << render_children(bytes, cursor, edit[:start] - cursor, nested, encloses)
91
112
  chunks << edit[:text]
92
113
  cursor = edit[:start] + edit[:length]
93
114
  end
94
- chunks << render_children(bytes, cursor, finish - cursor, nested)
115
+ chunks << render_children(bytes, cursor, finish - cursor, nested, encloses)
95
116
  chunks.join
96
117
  end
97
118
 
@@ -101,7 +122,15 @@ module Branchproof
101
122
  "#{runtime}.flow_finish(#{decision_id.inspect}, (#{expression}), #{default_path.inspect}); ensure; " \
102
123
  "#{runtime}.leave(#{decision_id.inspect}); end; end)"
103
124
  end
125
+
126
+ def flow_assignment_frame(decision_id, expression, default_path: nil)
127
+ runtime = self.class::RUNTIME
128
+ "(begin; #{runtime}.enter(#{decision_id.inspect}); begin; " \
129
+ "#{runtime}.flow_assignment_finish(#{decision_id.inspect}, (#{expression}), " \
130
+ "#{default_path.inspect}); ensure; " \
131
+ "#{runtime}.leave(#{decision_id.inspect}); end; end)"
132
+ end
104
133
  end
105
134
  end
106
135
 
107
- # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
136
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength, Metrics/ModuleLength
@@ -6,16 +6,23 @@ require "pathname"
6
6
  module Branchproof
7
7
  # Terminal renderings grouped around conditions or tests.
8
8
  class FocusedReport
9
- def initialize(document:, view:, level:, missing_only: false)
9
+ DECISION_TABLE_LABELS = { "true" => "T", "false" => "F", "dont_care" => "-" }.freeze
10
+
11
+ def initialize(document:, view:, level:, missing_only: false, coordinator: nil)
10
12
  @document = document || {}
11
13
  @view = view.to_sym
12
- raise ArgumentError, "view must be :conditions or :tests" unless %i[conditions tests].include?(@view)
14
+ unless (Report::VIEWS - [:decisions]).include?(@view)
15
+ raise ArgumentError, "view must be :conditions, :tests, or :decision_tables"
16
+ end
13
17
 
14
18
  @level = level.to_i
15
19
  @missing_only = missing_only ? true : false
16
20
  @index = CoverageIndex.new(document: @document)
17
21
  @tests = @index.tests.to_h { |test| [test[:id].to_s, test] }
18
- @coordinator = Report.from_document(document: @document, level: @level)
22
+ @test_name_counts = @index.tests.each_with_object(Hash.new(0)) do |test, counts|
23
+ counts[[test[:name], test[:relative_path], test[:line]]] += 1
24
+ end
25
+ @coordinator = coordinator || Report.from_document(document: @document, level: @level)
19
26
  end
20
27
 
21
28
  def render
@@ -39,14 +46,19 @@ module Branchproof
39
46
  lines << "Empty groups mean no recorded completed observation."
40
47
  lines << ""
41
48
  lines.concat(@coordinator.coverage_ladder_lines)
42
- if @view == :conditions
49
+ case @view
50
+ when :conditions
43
51
  render_conditions(lines)
44
52
  render_alternatives(lines)
53
+ when :decision_tables
54
+ render_decision_tables(lines)
45
55
  else
46
56
  render_tests(lines)
47
57
  end
48
- render_unowned(lines)
49
- render_unsupported(lines)
58
+ unless @view == :decision_tables
59
+ render_unowned(lines)
60
+ render_unsupported(lines)
61
+ end
50
62
  Array(fetch(@document, :diagnostics)).each do |diagnostic|
51
63
  lines << "Diagnostic: #{@coordinator.diagnostic_message(diagnostic)}"
52
64
  end
@@ -117,6 +129,64 @@ module Branchproof
117
129
  end
118
130
  end
119
131
 
132
+ # One block per Boolean decision that carries a decision table. With
133
+ # --missing-only only uncovered, non-impossible rules remain: a rule proven
134
+ # impossible is not a missing obligation.
135
+ def render_decision_tables(lines)
136
+ rows = @index.decision_tables
137
+ rendered = 0
138
+ impossible = 0
139
+ rows.each do |row|
140
+ impossible += row[:impossible_rules].to_i
141
+ rules = @missing_only ? row[:rules].select { |rule| rule[:coverage].to_s == "missing" } : row[:rules]
142
+ next if @missing_only && rules.empty? && row[:status].to_s == "calculated"
143
+
144
+ rendered += 1
145
+ lines << "Decision: #{row[:decision_expression]}"
146
+ lines << "Location: #{location(row[:relative_path], row[:line], unavailable: "decision line unavailable")}"
147
+ lines << "Context: #{row[:context]}" unless row[:context].to_s.empty?
148
+ if row[:status].to_s != "calculated"
149
+ lines << "Decision Table: NOT CALCULATED"
150
+ lines << "Reason: #{row[:reason]}"
151
+ lines << ""
152
+ next
153
+ end
154
+ lines << "Decision Table: #{row[:covered_rules]}/#{row[:required_rules]} rules covered" \
155
+ "#{" (#{row[:percentage]}%)" unless row[:percentage].nil?}"
156
+ lines << "Reachability: not analyzed" unless row[:reachability_analyzed]
157
+ rules.each { |rule| render_decision_table_rule(lines, row, rule) }
158
+ lines << ""
159
+ end
160
+ lines << "No missing decision-table rules" if @missing_only && rendered.zero?
161
+ return unless impossible.positive?
162
+
163
+ lines << "#{impossible} statically impossible rule#{"s" unless impossible == 1} excluded"
164
+ end
165
+
166
+ def render_decision_table_rule(lines, row, rule)
167
+ signature = rule[:conditions].map { |item| DECISION_TABLE_LABELS.fetch(item.to_s, item.to_s) }.join
168
+ status = rule[:coverage].to_s.upcase
169
+ lines << " #{rule[:label]} #{signature} => #{rule[:outcome] ? "T" : "F"} #{status}"
170
+ row[:conditions].each_with_index do |expression, index|
171
+ requirement = @coordinator.decision_table_requirement(rule[:conditions][index])
172
+ next if requirement.nil?
173
+
174
+ lines << " #{expression} = #{requirement}"
175
+ end
176
+ lines << " #{@coordinator.decision_table_expected_heading(row)} #{rule[:outcome] ? "true" : "false"}"
177
+ if rule[:coverage].to_s == "covered"
178
+ owners = Array(rule[:tests]).map { |id| test_label(id) }
179
+ owners << "unattributed" if rule[:unattributed_count].to_i.positive?
180
+ lines << " Tests: #{owners.empty? ? "none recorded" : owners.uniq.join(", ")}"
181
+ else
182
+ lines << " Tests: NOT COVERED"
183
+ end
184
+ lines << " Reachability: #{@coordinator.decision_table_reachability(rule)}"
185
+ return if rule[:reachability_reason].to_s.empty?
186
+
187
+ lines << " Reason: #{Constraints.message(rule[:reachability_reason])}"
188
+ end
189
+
120
190
  def render_alternatives(lines)
121
191
  rows = @index.alternatives
122
192
  rows = rows.select { |row| row[:missing] || row[:status].to_s != "covered" } if @missing_only && @level > 1
@@ -271,9 +341,7 @@ module Branchproof
271
341
  return id.to_s unless test
272
342
 
273
343
  label = "#{test[:name]} (#{location(test[:relative_path], test[:line])})"
274
- duplicates = @index.tests.count do |row|
275
- [row[:name], row[:relative_path], row[:line]] == [test[:name], test[:relative_path], test[:line]]
276
- end
344
+ duplicates = @test_name_counts[[test[:name], test[:relative_path], test[:line]]]
277
345
  duplicates > 1 ? "#{label} [#{id}]" : label
278
346
  end
279
347
 
@@ -1,6 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "flow_instrumentation"
4
+ require_relative "iteration_instrumentation"
5
+ require_relative "exception_instrumentation"
6
+ require_relative "default_instrumentation"
7
+ require_relative "value_instrumentation"
4
8
 
5
9
  module Branchproof
6
10
  # Applies the smallest possible source edits around inventoried expressions.
@@ -8,23 +12,24 @@ module Branchproof
8
12
  # never evaluates application code or introduces a Ruby scope.
9
13
  class Instrumenter
10
14
  include FlowInstrumentation
15
+ include IterationInstrumentation
16
+ include ExceptionInstrumentation
17
+ prepend DefaultInstrumentation
18
+ prepend ValueInstrumentation
11
19
 
12
20
  RUNTIME = "::Branchproof::Runtime"
13
21
 
14
22
  def rewrite(unit:)
15
23
  bytes = unit.fetch(:original_bytes).dup.force_encoding(Encoding::BINARY)
16
24
  reasons = Array(unit[:support_reasons])
17
- unless supported_unit?(unit, reasons)
18
- return result(bytes, diagnostics: [diagnostic("unsupported_source", reasons.join(", "))])
19
- end
25
+ return result(bytes, diagnostics: [diagnostic("unsupported_source", reasons.join(", "))]) unless supported?(unit)
20
26
 
21
27
  decisions = Array(unit[:decisions]).select { |decision| supported?(decision) }
28
+ encloses, enclosed = build_enclosures(decisions)
22
29
  edits = decisions.filter_map do |decision|
23
- next if decisions.any? do |outer|
24
- encloses_decision?(outer, decision)
25
- end
30
+ next if enclosed[decision]
26
31
 
27
- decision_edit(bytes, decision, decisions)
32
+ decision_edit(bytes, decision, encloses)
28
33
  end
29
34
  if decisions.any? && edits.empty?
30
35
  return result(bytes,
@@ -34,35 +39,65 @@ module Branchproof
34
39
 
35
40
  rewritten = apply_edits(bytes, edits)
36
41
  begin
37
- RubyVM::InstructionSequence.compile(rewritten, unit[:absolute_path] || "(branchproof)",
38
- unit[:real_path] || unit[:absolute_path] || "(branchproof)", 1)
42
+ iseq = RubyVM::InstructionSequence.compile(rewritten, unit[:absolute_path] || "(branchproof)",
43
+ unit[:real_path] || unit[:absolute_path] || "(branchproof)", 1)
39
44
  rescue SyntaxError => e
40
- return result(bytes, diagnostics: [diagnostic("invalid_rewrite", e.message)])
45
+ return result(rewritten, diagnostics: [diagnostic("invalid_rewrite", e.message)])
41
46
  end
42
- result(rewritten.force_encoding(unit[:original_bytes].encoding), changed: rewritten != bytes)
47
+ result(rewritten.force_encoding(unit[:original_bytes].encoding), changed: edits.any?, iseq: iseq)
43
48
  end
44
49
 
45
50
  private
46
51
 
47
- def supported_unit?(unit, _reasons)
48
- status = unit[:support_status]
52
+ def supported?(record)
53
+ status = record[:support_status]
49
54
  status.nil? || status.to_s.casecmp("supported").zero?
50
55
  end
51
56
 
52
- def supported?(decision)
53
- status = decision[:support_status]
54
- status.nil? || status.to_s.casecmp("supported").zero?
57
+ # Precompute, once, which decisions each decision encloses (and whether it is
58
+ # itself enclosed by any other), so rendering never re-scans the full decision
59
+ # list at every recursion level. Safe because `encloses_decision?` containment
60
+ # is transitive: if the encloses map for `decision` says it contains X, that
61
+ # holds true within any nested subset that already contains `decision`.
62
+ def build_enclosures(decisions)
63
+ encloses = {}.compare_by_identity
64
+ enclosed = {}.compare_by_identity
65
+ indexed = decisions.each_index.group_by { |index| decisions[index][:byte_start] }
66
+ active = []
67
+ relations = Array.new(decisions.length) { [] }
68
+
69
+ indexed.sort_by { |start, _indices| start }.each do |start, indices|
70
+ active.reject! do |index|
71
+ decision = decisions[index]
72
+ decision[:byte_start] + decision[:byte_length] < start
73
+ end
74
+ active.concat(indices)
75
+ indices.each do |inner_index|
76
+ inner = decisions[inner_index]
77
+ active.each do |outer_index|
78
+ outer = decisions[outer_index]
79
+ next unless encloses_decision?(outer, inner)
80
+
81
+ relations[outer_index] << inner_index
82
+ end
83
+ end
84
+ end
85
+
86
+ decisions.each_with_index do |outer, outer_index|
87
+ contained = relations[outer_index].sort.map { |inner_index| decisions[inner_index] }
88
+ encloses[outer] = contained
89
+ contained.each { |inner| enclosed[inner] = true }
90
+ end
91
+ [encloses, enclosed]
55
92
  end
56
93
 
57
- def decision_edit(bytes, decision, all_decisions)
94
+ def decision_edit(bytes, decision, encloses)
58
95
  start = decision[:byte_start]
59
96
  length = decision[:byte_length]
60
97
  return nil unless valid_range?(bytes, start, length)
61
98
 
62
- nested = all_decisions.select do |candidate|
63
- encloses_decision?(decision, candidate)
64
- end
65
- expression = render_decision(bytes, decision, nested)
99
+ nested = encloses[decision] || []
100
+ expression = render_decision(bytes, decision, nested, encloses)
66
101
  {
67
102
  start: start,
68
103
  finish: start + length,
@@ -70,7 +105,9 @@ module Branchproof
70
105
  }
71
106
  end
72
107
 
73
- def render_range(bytes, start, length, decision, nested)
108
+ def render_range(bytes, decision, nested, encloses)
109
+ start = decision[:byte_start]
110
+ length = decision[:byte_length]
74
111
  conditions = Array(decision[:conditions]).sort_by { |condition| condition[:byte_start] }
75
112
  cursor = start
76
113
  chunks = []
@@ -80,15 +117,15 @@ module Branchproof
80
117
  next unless valid_range?(bytes, cstart, clen) && cstart >= start && cstart + clen <= start + length
81
118
 
82
119
  chunks << bytes.byteslice(cursor, cstart - cursor)
83
- original = render_children(bytes, cstart, clen, nested)
84
- chunks << condition_wrapper(decision[:id], condition[:index], original)
120
+ original = render_children(bytes, cstart, clen, nested, encloses)
121
+ chunks << condition_wrapper(decision, condition, original)
85
122
  cursor = cstart + clen
86
123
  end
87
- chunks << render_children(bytes, cursor, length - (cursor - start), nested)
124
+ chunks << render_children(bytes, cursor, length - (cursor - start), nested, encloses)
88
125
  chunks.join
89
126
  end
90
127
 
91
- def render_children(bytes, start, length, nested)
128
+ def render_children(bytes, start, length, nested, encloses)
92
129
  children = nested.select do |child|
93
130
  contains?(start, length, child[:byte_start], child[:byte_length])
94
131
  end
@@ -98,8 +135,8 @@ module Branchproof
98
135
  children.sort_by! { |child| -child[:byte_start] }
99
136
  output = bytes.byteslice(start, length)
100
137
  children.each do |child|
101
- descendants = nested.select { |candidate| encloses_decision?(child, candidate) }
102
- child_text = render_decision(bytes, child, descendants)
138
+ descendants = encloses[child] || []
139
+ child_text = render_decision(bytes, child, descendants, encloses)
103
140
  offset = child[:byte_start] - start
104
141
  output[offset, child[:byte_length]] = child_text
105
142
  end
@@ -114,17 +151,33 @@ module Branchproof
114
151
  (outer[:kind] || "boolean") == "boolean" && inner[:kind] != "boolean" && !inner[:kind].nil?
115
152
  end
116
153
 
117
- def render_decision(bytes, decision, nested)
154
+ def render_decision(bytes, decision, nested, encloses)
118
155
  if decision[:instrumentation]
119
- render_flow(bytes, decision, nested)
156
+ render_flow(bytes, decision, nested, encloses)
120
157
  else
121
- expression = render_range(bytes, decision[:byte_start], decision[:byte_length], decision, nested)
158
+ expression = render_range(bytes, decision, nested, encloses)
122
159
  frame(decision[:id], expression)
123
160
  end
124
161
  end
125
162
 
126
- def condition_wrapper(decision_id, index, expression)
127
- "#{RUNTIME}.condition(#{decision_id.inspect}, #{index}, (#{expression}))"
163
+ def condition_wrapper(decision, condition, expression)
164
+ return "(begin; #{expression}; end)" if nonlocal_transfer?(expression)
165
+
166
+ identifier = decision[:id].inspect
167
+ index = condition[:index]
168
+ case condition[:contextual]
169
+ when "implicit_regexp", "flip_flop"
170
+ # These operations require Ruby's conditional context: a bare regexp
171
+ # implicitly matches $_ and a flip-flop retains its state across calls.
172
+ "(if (#{expression}) then #{RUNTIME}.condition(#{identifier}, #{index}, true) " \
173
+ "else #{RUNTIME}.condition(#{identifier}, #{index}, false) end)"
174
+ else
175
+ "#{RUNTIME}.condition(#{identifier}, #{index}, (#{expression}))"
176
+ end
177
+ end
178
+
179
+ def nonlocal_transfer?(expression)
180
+ expression.lstrip.match?(/\A(?:return|break|next|redo|retry)\b/n)
128
181
  end
129
182
 
130
183
  def frame(decision_id, expression)
@@ -133,10 +186,13 @@ module Branchproof
133
186
  "#{RUNTIME}.leave(#{decision_id.inspect}); end; end)"
134
187
  end
135
188
 
189
+ # Mutates `bytes` in place: it is already a fresh copy made at the top of
190
+ # rewrite, so there is no need to copy it again before splicing edits in.
136
191
  def apply_edits(bytes, edits)
137
- edits.sort_by { |edit| -edit[:start] }.each_with_object(bytes.dup) do |edit, output|
138
- output[edit[:start]...edit[:finish]] = edit[:text]
192
+ edits.sort_by { |edit| -edit[:start] }.each do |edit|
193
+ bytes[edit[:start]...edit[:finish]] = edit[:text]
139
194
  end
195
+ bytes
140
196
  end
141
197
 
142
198
  def valid_range?(bytes, start, length)
@@ -153,8 +209,8 @@ module Branchproof
153
209
  start.is_a?(Integer) && length.is_a?(Integer) && start >= 0 && length >= 0
154
210
  end
155
211
 
156
- def result(bytes, changed: false, diagnostics: [])
157
- { bytes: bytes, changed: changed, diagnostics: diagnostics }.freeze
212
+ def result(bytes, changed: false, diagnostics: [], iseq: nil)
213
+ { bytes: bytes, changed: changed, diagnostics: diagnostics, iseq: iseq }.freeze
158
214
  end
159
215
 
160
216
  def diagnostic(code, message)
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Branchproof
4
+ # Keep matching and iteration in the original lexical scope.
5
+ module IterationInstrumentation
6
+ private
7
+
8
+ # rubocop:disable Metrics/MethodLength
9
+ def render_flow(bytes, decision, nested, encloses)
10
+ metadata = decision.fetch(:instrumentation)
11
+ case metadata[:type]
12
+ when "iteration"
13
+ render_iteration(bytes, decision, nested, encloses)
14
+ when "required_pattern"
15
+ expression = render_children(bytes, decision[:byte_start], decision[:byte_length], nested, encloses)
16
+ runtime = self.class::RUNTIME
17
+ identifier = decision[:id].inspect
18
+ "(begin; #{runtime}.enter(#{identifier}); begin; #{expression}; " \
19
+ "#{runtime}.flow_select(#{identifier}, 0); nil; rescue ::NoMatchingPatternError; " \
20
+ "#{runtime}.flow_select(#{identifier}, 1); raise; ensure; #{runtime}.leave(#{identifier}); end; end)"
21
+ else
22
+ super
23
+ end
24
+ end
25
+
26
+ # rubocop:enable Metrics/MethodLength
27
+
28
+ # rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity -- nested exception callback seam
29
+ def render_iteration(bytes, decision, nested, encloses)
30
+ metadata = decision.fetch(:instrumentation)
31
+ identifier = decision[:id].inspect
32
+ runtime = self.class::RUNTIME
33
+ alternative_count = metadata[:lazy] ? 1 : 2
34
+ marker = "#{runtime}.flow_iteration_callback(#{identifier}, #{alternative_count})"
35
+ suffix = metadata[:empty] ? "nil; " : ""
36
+ callback = "; #{marker}; #{suffix}"
37
+ exception_child = nested.find do |child|
38
+ child[:instrumentation]&.fetch(:type, nil) == "exception" &&
39
+ child[:instrumentation].fetch(:implicit, false) &&
40
+ contains?(child[:byte_start], child[:byte_length], metadata[:insert_at], 0)
41
+ end
42
+ if exception_child
43
+ instrumentation = exception_child.fetch(:instrumentation)
44
+ callback_child = exception_child.merge(instrumentation: instrumentation.merge(
45
+ iteration_callback: { insert_at: metadata[:insert_at], text: callback }
46
+ ))
47
+ nested = nested.map { |child| child.equal?(exception_child) ? callback_child : child }
48
+ encloses = encloses.merge(callback_child => encloses[exception_child])
49
+ end
50
+ edits = exception_child ? [] : [{ start: metadata[:insert_at], length: 0, text: callback }]
51
+ if metadata[:receiver]
52
+ receiver = metadata.fetch(:receiver)
53
+ edits << flow_replacement(bytes, receiver, nested, encloses) do |expression|
54
+ "#{runtime}.flow_iteration_begin(#{identifier}, (#{expression}), #{alternative_count})"
55
+ end
56
+ end
57
+ expression = flow_fragments(bytes, decision, nested, edits, encloses)
58
+ if metadata[:receiver]
59
+ "(begin; begin; #{runtime}.flow_iteration_finish(#{identifier}, (#{expression}), 0); ensure; " \
60
+ "#{runtime}.flow_iteration_leave(#{identifier}); end; end)"
61
+ else
62
+ flow_frame(decision[:id], expression, default_path: 0)
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Branchproof
4
+ # Runtime support for callback based iteration, including lazy receivers
5
+ # whose callbacks execute after the constructing call has returned.
6
+ module IterationRuntime
7
+ def flow_iteration_begin(decision_id, receiver, alternative_count = 2)
8
+ # Case equality bypasses an application's overridden `is_a?` method.
9
+ # rubocop:disable-next Style/CaseEquality
10
+ return receiver if alternative_count == 1 || ::Enumerator::Lazy === receiver
11
+
12
+ enter(decision_id)
13
+ receiver
14
+ end
15
+
16
+ # rubocop:disable-next Metrics/MethodLength
17
+ def flow_iteration_callback(decision_id, alternative_count = 2)
18
+ frame = iteration_frame(decision_id)
19
+ if frame
20
+ return nil if frame[:finished]
21
+
22
+ iteration_select(frame, alternative_count.to_i == 1 ? 0 : 1, alternative_count)
23
+ else
24
+ enter(decision_id)
25
+ begin
26
+ if alternative_count.to_i == 1
27
+ flow_select(decision_id, 0)
28
+ else
29
+ flow_path(decision_id, 1)
30
+ end
31
+ ensure
32
+ leave(decision_id)
33
+ end
34
+ end
35
+ nil
36
+ end
37
+
38
+ def flow_iteration_finish(decision_id, value, default_path = nil)
39
+ frame = iteration_frame(decision_id)
40
+ iteration_select(frame, default_path, 2) if frame && !frame[:finished] && !default_path.nil?
41
+ value
42
+ end
43
+
44
+ def flow_iteration_leave(decision_id)
45
+ frame = iteration_frame(decision_id)
46
+ frames = Thread.current[Branchproof::Runtime::FRAME_STATE_KEY]&.fetch(:frames, nil)
47
+ return nil unless frame && frames&.last.equal?(frame)
48
+
49
+ leave(decision_id)
50
+ end
51
+
52
+ private
53
+
54
+ # `current_frame` deliberately latches a diagnostic when a helper probes
55
+ # while another decision is active. Iteration callbacks may be deferred or
56
+ # nested, so an absent matching frame is an ordinary state here.
57
+ def iteration_frame(decision_id)
58
+ frames = Thread.current[Branchproof::Runtime::FRAME_STATE_KEY]&.fetch(:frames, nil)
59
+ index = frames ? frames.length - 1 : -1
60
+ while index >= 0
61
+ frame = frames[index]
62
+ return frame if frame[:decision_id] == decision_id
63
+
64
+ index -= 1
65
+ end
66
+ nil
67
+ end
68
+
69
+ def iteration_select(frame, index, alternative_count)
70
+ frame[:observations] = if alternative_count == 1
71
+ [[0, true]]
72
+ else
73
+ [[0, index.zero?], [1, index == 1]]
74
+ end
75
+ frame[:outcome] = true
76
+ frame[:finished] = true
77
+ end
78
+ end
79
+ end