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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +53 -0
- data/README.md +255 -21
- data/doc/Branchproof/Analyzer.md +2 -2
- data/doc/Branchproof/CLI.md +4 -0
- data/doc/Branchproof/ComparisonReport.md +4 -0
- data/doc/Branchproof/Constraints/Solver.md +21 -0
- data/doc/Branchproof/Constraints.md +98 -0
- data/doc/Branchproof/CoverageIndex.md +3 -0
- data/doc/Branchproof/DecisionSyntax.md +6 -4
- data/doc/Branchproof/DecisionTable.md +153 -0
- data/doc/Branchproof/DefaultInstrumentation.md +13 -0
- data/doc/Branchproof/DefaultRuntime.md +11 -0
- data/doc/Branchproof/DefaultSyntax.md +7 -0
- data/doc/Branchproof/ExceptionInstrumentation.md +9 -0
- data/doc/Branchproof/ExceptionRuntime.md +27 -0
- data/doc/Branchproof/ExceptionSyntax.md +9 -0
- data/doc/Branchproof/ExtendedAlternativeRuntime.md +19 -0
- data/doc/Branchproof/FocusedReport.md +5 -2
- data/doc/Branchproof/Instrumenter.md +1 -1
- data/doc/Branchproof/IterationInstrumentation.md +7 -0
- data/doc/Branchproof/IterationRuntime.md +21 -0
- data/doc/Branchproof/IterationSyntax.md +11 -0
- data/doc/Branchproof/Loader.md +0 -4
- data/doc/Branchproof/MinitestAdapter.md +0 -3
- data/doc/Branchproof/Report.md +18 -0
- data/doc/Branchproof/Runtime.md +60 -1
- data/doc/Branchproof/SavedReport.md +15 -0
- data/doc/Branchproof/Source.md +7 -1
- data/doc/Branchproof/ValueInstrumentation.md +7 -0
- data/doc/Branchproof/ValueRuntime.md +26 -0
- data/doc/Branchproof/ValueSyntax.md +26 -0
- data/doc/Branchproof.md +20 -3
- data/doc/CHANGELOG.md +53 -0
- data/doc/README.md +255 -21
- data/lib/branchproof/analyzer.rb +80 -44
- data/lib/branchproof/cli.rb +28 -19
- data/lib/branchproof/comparison.rb +207 -19
- data/lib/branchproof/comparison_report.rb +49 -1
- data/lib/branchproof/constraints.rb +363 -0
- data/lib/branchproof/coverage_index.rb +30 -1
- data/lib/branchproof/decision_syntax.rb +50 -24
- data/lib/branchproof/decision_table.rb +377 -0
- data/lib/branchproof/default_instrumentation.rb +140 -0
- data/lib/branchproof/default_runtime.rb +16 -0
- data/lib/branchproof/default_syntax.rb +94 -0
- data/lib/branchproof/evidence.rb +133 -31
- data/lib/branchproof/exception_instrumentation.rb +96 -0
- data/lib/branchproof/exception_runtime.rb +41 -0
- data/lib/branchproof/exception_syntax.rb +157 -0
- data/lib/branchproof/extended_alternative_runtime.rb +23 -0
- data/lib/branchproof/flow_instrumentation.rb +52 -23
- data/lib/branchproof/focused_report.rb +77 -9
- data/lib/branchproof/instrumenter.rb +93 -37
- data/lib/branchproof/iteration_instrumentation.rb +66 -0
- data/lib/branchproof/iteration_runtime.rb +79 -0
- data/lib/branchproof/iteration_syntax.rb +75 -0
- data/lib/branchproof/limits.rb +4 -1
- data/lib/branchproof/loader.rb +24 -7
- data/lib/branchproof/minimizer.rb +18 -13
- data/lib/branchproof/minitest_adapter.rb +11 -16
- data/lib/branchproof/records.rb +2 -0
- data/lib/branchproof/report.rb +301 -68
- data/lib/branchproof/runtime.rb +32 -30
- data/lib/branchproof/saved_report.rb +200 -14
- data/lib/branchproof/source.rb +140 -71
- data/lib/branchproof/value_instrumentation.rb +35 -0
- data/lib/branchproof/value_runtime.rb +73 -0
- data/lib/branchproof/value_syntax.rb +116 -0
- data/lib/branchproof/version.rb +1 -1
- data/lib/branchproof/worker.rb +1 -4
- data/lib/branchproof.rb +2 -0
- data/llms.txt +20 -3
- data/sig/branchproof.rbs +64 -1
- 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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
@
|
|
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
|
-
|
|
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
|
-
|
|
49
|
-
|
|
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 = @
|
|
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
|
-
|
|
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
|
|
24
|
-
encloses_decision?(outer, decision)
|
|
25
|
-
end
|
|
30
|
+
next if enclosed[decision]
|
|
26
31
|
|
|
27
|
-
decision_edit(bytes, decision,
|
|
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
|
-
|
|
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(
|
|
45
|
+
return result(rewritten, diagnostics: [diagnostic("invalid_rewrite", e.message)])
|
|
41
46
|
end
|
|
42
|
-
result(rewritten.force_encoding(unit[:original_bytes].encoding), changed:
|
|
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
|
|
48
|
-
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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,
|
|
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 =
|
|
63
|
-
|
|
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,
|
|
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
|
|
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 =
|
|
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
|
|
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(
|
|
127
|
-
"
|
|
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] }.
|
|
138
|
-
|
|
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
|