branchproof 0.7.0 → 0.8.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 +35 -0
- data/README.md +212 -11
- 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/FocusedReport.md +5 -2
- 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 +4 -0
- data/doc/Branchproof/SavedReport.md +15 -0
- data/doc/Branchproof.md +4 -1
- data/doc/CHANGELOG.md +35 -0
- data/doc/README.md +212 -11
- 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 +31 -15
- data/lib/branchproof/decision_table.rb +377 -0
- data/lib/branchproof/evidence.rb +51 -31
- data/lib/branchproof/flow_instrumentation.rb +17 -17
- data/lib/branchproof/focused_report.rb +77 -9
- data/lib/branchproof/instrumenter.rb +45 -34
- data/lib/branchproof/limits.rb +4 -1
- data/lib/branchproof/loader.rb +18 -6
- 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 +22 -30
- data/lib/branchproof/saved_report.rb +200 -14
- data/lib/branchproof/source.rb +90 -48
- data/lib/branchproof/version.rb +1 -1
- data/lib/branchproof/worker.rb +1 -4
- data/lib/branchproof.rb +2 -0
- data/llms.txt +4 -1
- data/sig/branchproof.rbs +47 -1
- metadata +6 -1
|
@@ -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
|
|
|
@@ -14,17 +14,14 @@ module Branchproof
|
|
|
14
14
|
def rewrite(unit:)
|
|
15
15
|
bytes = unit.fetch(:original_bytes).dup.force_encoding(Encoding::BINARY)
|
|
16
16
|
reasons = Array(unit[:support_reasons])
|
|
17
|
-
|
|
18
|
-
return result(bytes, diagnostics: [diagnostic("unsupported_source", reasons.join(", "))])
|
|
19
|
-
end
|
|
17
|
+
return result(bytes, diagnostics: [diagnostic("unsupported_source", reasons.join(", "))]) unless supported?(unit)
|
|
20
18
|
|
|
21
19
|
decisions = Array(unit[:decisions]).select { |decision| supported?(decision) }
|
|
20
|
+
encloses, enclosed = build_enclosures(decisions)
|
|
22
21
|
edits = decisions.filter_map do |decision|
|
|
23
|
-
next if
|
|
24
|
-
encloses_decision?(outer, decision)
|
|
25
|
-
end
|
|
22
|
+
next if enclosed[decision]
|
|
26
23
|
|
|
27
|
-
decision_edit(bytes, decision,
|
|
24
|
+
decision_edit(bytes, decision, encloses)
|
|
28
25
|
end
|
|
29
26
|
if decisions.any? && edits.empty?
|
|
30
27
|
return result(bytes,
|
|
@@ -34,35 +31,44 @@ module Branchproof
|
|
|
34
31
|
|
|
35
32
|
rewritten = apply_edits(bytes, edits)
|
|
36
33
|
begin
|
|
37
|
-
RubyVM::InstructionSequence.compile(rewritten, unit[:absolute_path] || "(branchproof)",
|
|
38
|
-
|
|
34
|
+
iseq = RubyVM::InstructionSequence.compile(rewritten, unit[:absolute_path] || "(branchproof)",
|
|
35
|
+
unit[:real_path] || unit[:absolute_path] || "(branchproof)", 1)
|
|
39
36
|
rescue SyntaxError => e
|
|
40
|
-
return result(
|
|
37
|
+
return result(rewritten, diagnostics: [diagnostic("invalid_rewrite", e.message)])
|
|
41
38
|
end
|
|
42
|
-
result(rewritten.force_encoding(unit[:original_bytes].encoding), changed:
|
|
39
|
+
result(rewritten.force_encoding(unit[:original_bytes].encoding), changed: edits.any?, iseq: iseq)
|
|
43
40
|
end
|
|
44
41
|
|
|
45
42
|
private
|
|
46
43
|
|
|
47
|
-
def
|
|
48
|
-
status =
|
|
44
|
+
def supported?(record)
|
|
45
|
+
status = record[:support_status]
|
|
49
46
|
status.nil? || status.to_s.casecmp("supported").zero?
|
|
50
47
|
end
|
|
51
48
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
49
|
+
# Precompute, once, which decisions each decision encloses (and whether it is
|
|
50
|
+
# itself enclosed by any other), so rendering never re-scans the full decision
|
|
51
|
+
# list at every recursion level. Safe because `encloses_decision?` containment
|
|
52
|
+
# is transitive: if the encloses map for `decision` says it contains X, that
|
|
53
|
+
# holds true within any nested subset that already contains `decision`.
|
|
54
|
+
def build_enclosures(decisions)
|
|
55
|
+
encloses = {}.compare_by_identity
|
|
56
|
+
enclosed = {}.compare_by_identity
|
|
57
|
+
decisions.each do |outer|
|
|
58
|
+
contained = decisions.select { |inner| encloses_decision?(outer, inner) }
|
|
59
|
+
encloses[outer] = contained
|
|
60
|
+
contained.each { |inner| enclosed[inner] = true }
|
|
61
|
+
end
|
|
62
|
+
[encloses, enclosed]
|
|
55
63
|
end
|
|
56
64
|
|
|
57
|
-
def decision_edit(bytes, decision,
|
|
65
|
+
def decision_edit(bytes, decision, encloses)
|
|
58
66
|
start = decision[:byte_start]
|
|
59
67
|
length = decision[:byte_length]
|
|
60
68
|
return nil unless valid_range?(bytes, start, length)
|
|
61
69
|
|
|
62
|
-
nested =
|
|
63
|
-
|
|
64
|
-
end
|
|
65
|
-
expression = render_decision(bytes, decision, nested)
|
|
70
|
+
nested = encloses[decision] || []
|
|
71
|
+
expression = render_decision(bytes, decision, nested, encloses)
|
|
66
72
|
{
|
|
67
73
|
start: start,
|
|
68
74
|
finish: start + length,
|
|
@@ -70,7 +76,9 @@ module Branchproof
|
|
|
70
76
|
}
|
|
71
77
|
end
|
|
72
78
|
|
|
73
|
-
def render_range(bytes,
|
|
79
|
+
def render_range(bytes, decision, nested, encloses)
|
|
80
|
+
start = decision[:byte_start]
|
|
81
|
+
length = decision[:byte_length]
|
|
74
82
|
conditions = Array(decision[:conditions]).sort_by { |condition| condition[:byte_start] }
|
|
75
83
|
cursor = start
|
|
76
84
|
chunks = []
|
|
@@ -80,15 +88,15 @@ module Branchproof
|
|
|
80
88
|
next unless valid_range?(bytes, cstart, clen) && cstart >= start && cstart + clen <= start + length
|
|
81
89
|
|
|
82
90
|
chunks << bytes.byteslice(cursor, cstart - cursor)
|
|
83
|
-
original = render_children(bytes, cstart, clen, nested)
|
|
91
|
+
original = render_children(bytes, cstart, clen, nested, encloses)
|
|
84
92
|
chunks << condition_wrapper(decision[:id], condition[:index], original)
|
|
85
93
|
cursor = cstart + clen
|
|
86
94
|
end
|
|
87
|
-
chunks << render_children(bytes, cursor, length - (cursor - start), nested)
|
|
95
|
+
chunks << render_children(bytes, cursor, length - (cursor - start), nested, encloses)
|
|
88
96
|
chunks.join
|
|
89
97
|
end
|
|
90
98
|
|
|
91
|
-
def render_children(bytes, start, length, nested)
|
|
99
|
+
def render_children(bytes, start, length, nested, encloses)
|
|
92
100
|
children = nested.select do |child|
|
|
93
101
|
contains?(start, length, child[:byte_start], child[:byte_length])
|
|
94
102
|
end
|
|
@@ -98,8 +106,8 @@ module Branchproof
|
|
|
98
106
|
children.sort_by! { |child| -child[:byte_start] }
|
|
99
107
|
output = bytes.byteslice(start, length)
|
|
100
108
|
children.each do |child|
|
|
101
|
-
descendants =
|
|
102
|
-
child_text = render_decision(bytes, child, descendants)
|
|
109
|
+
descendants = encloses[child] || []
|
|
110
|
+
child_text = render_decision(bytes, child, descendants, encloses)
|
|
103
111
|
offset = child[:byte_start] - start
|
|
104
112
|
output[offset, child[:byte_length]] = child_text
|
|
105
113
|
end
|
|
@@ -114,11 +122,11 @@ module Branchproof
|
|
|
114
122
|
(outer[:kind] || "boolean") == "boolean" && inner[:kind] != "boolean" && !inner[:kind].nil?
|
|
115
123
|
end
|
|
116
124
|
|
|
117
|
-
def render_decision(bytes, decision, nested)
|
|
125
|
+
def render_decision(bytes, decision, nested, encloses)
|
|
118
126
|
if decision[:instrumentation]
|
|
119
|
-
render_flow(bytes, decision, nested)
|
|
127
|
+
render_flow(bytes, decision, nested, encloses)
|
|
120
128
|
else
|
|
121
|
-
expression = render_range(bytes, decision
|
|
129
|
+
expression = render_range(bytes, decision, nested, encloses)
|
|
122
130
|
frame(decision[:id], expression)
|
|
123
131
|
end
|
|
124
132
|
end
|
|
@@ -133,10 +141,13 @@ module Branchproof
|
|
|
133
141
|
"#{RUNTIME}.leave(#{decision_id.inspect}); end; end)"
|
|
134
142
|
end
|
|
135
143
|
|
|
144
|
+
# Mutates `bytes` in place: it is already a fresh copy made at the top of
|
|
145
|
+
# rewrite, so there is no need to copy it again before splicing edits in.
|
|
136
146
|
def apply_edits(bytes, edits)
|
|
137
|
-
edits.sort_by { |edit| -edit[:start] }.
|
|
138
|
-
|
|
147
|
+
edits.sort_by { |edit| -edit[:start] }.each do |edit|
|
|
148
|
+
bytes[edit[:start]...edit[:finish]] = edit[:text]
|
|
139
149
|
end
|
|
150
|
+
bytes
|
|
140
151
|
end
|
|
141
152
|
|
|
142
153
|
def valid_range?(bytes, start, length)
|
|
@@ -153,8 +164,8 @@ module Branchproof
|
|
|
153
164
|
start.is_a?(Integer) && length.is_a?(Integer) && start >= 0 && length >= 0
|
|
154
165
|
end
|
|
155
166
|
|
|
156
|
-
def result(bytes, changed: false, diagnostics: [])
|
|
157
|
-
{ bytes: bytes, changed: changed, diagnostics: diagnostics }.freeze
|
|
167
|
+
def result(bytes, changed: false, diagnostics: [], iseq: nil)
|
|
168
|
+
{ bytes: bytes, changed: changed, diagnostics: diagnostics, iseq: iseq }.freeze
|
|
158
169
|
end
|
|
159
170
|
|
|
160
171
|
def diagnostic(code, message)
|
data/lib/branchproof/limits.rb
CHANGED
|
@@ -6,6 +6,7 @@ module Branchproof
|
|
|
6
6
|
KEYS = %i[
|
|
7
7
|
conditions_per_decision vectors_per_decision owner_associations_per_run
|
|
8
8
|
tests_per_run exact_candidates exact_search_nodes constraint_search_states
|
|
9
|
+
max_conditions_for_decision_table decision_table_rules_per_decision
|
|
9
10
|
].freeze
|
|
10
11
|
DEFAULTS = {
|
|
11
12
|
conditions_per_decision: 64,
|
|
@@ -14,7 +15,9 @@ module Branchproof
|
|
|
14
15
|
tests_per_run: 50_000,
|
|
15
16
|
exact_candidates: 32,
|
|
16
17
|
exact_search_nodes: 100_000,
|
|
17
|
-
constraint_search_states: 10_000
|
|
18
|
+
constraint_search_states: 10_000,
|
|
19
|
+
max_conditions_for_decision_table: 12,
|
|
20
|
+
decision_table_rules_per_decision: 4096
|
|
18
21
|
}.freeze
|
|
19
22
|
|
|
20
23
|
module_function
|
data/lib/branchproof/loader.rb
CHANGED
|
@@ -5,8 +5,6 @@ require "digest"
|
|
|
5
5
|
module Branchproof
|
|
6
6
|
# Owns the process-local CRuby compilation hook when the VM exposes it.
|
|
7
7
|
class Loader
|
|
8
|
-
STATUS_KEYS = %i[status reason].freeze
|
|
9
|
-
|
|
10
8
|
def initialize(inventory:, instrumenter:)
|
|
11
9
|
@inventory = inventory
|
|
12
10
|
@instrumenter = instrumenter
|
|
@@ -41,7 +39,7 @@ module Branchproof
|
|
|
41
39
|
def load_iseq(path)
|
|
42
40
|
return nil unless @installed
|
|
43
41
|
|
|
44
|
-
unit = @units[canonical(path)]
|
|
42
|
+
unit = @units[path] || @units[canonical(path)]
|
|
45
43
|
return nil unless unit
|
|
46
44
|
|
|
47
45
|
bytes = File.binread(path)
|
|
@@ -61,6 +59,8 @@ module Branchproof
|
|
|
61
59
|
return nil
|
|
62
60
|
end
|
|
63
61
|
|
|
62
|
+
return rewritten[:iseq] if reusable_iseq?(unit, rewritten)
|
|
63
|
+
|
|
64
64
|
RubyVM::InstructionSequence.compile(
|
|
65
65
|
rewritten[:bytes], unit[:real_path] || canonical(path), unit[:real_path] || canonical(path), 1,
|
|
66
66
|
compile_options(unit)
|
|
@@ -108,13 +108,14 @@ module Branchproof
|
|
|
108
108
|
end
|
|
109
109
|
|
|
110
110
|
def preloaded_target?
|
|
111
|
-
|
|
111
|
+
loaded = $LOADED_FEATURES.each_with_object(Set.new) { |feature, set| set << canonical(feature) }
|
|
112
|
+
@units.keys.any? { |path| loaded.include?(canonical(path)) }
|
|
112
113
|
end
|
|
113
114
|
|
|
114
115
|
def index_units(inventory)
|
|
115
116
|
Array(inventory[:source_units]).each_with_object({}) do |unit, index|
|
|
116
|
-
|
|
117
|
-
index[
|
|
117
|
+
index[unit[:absolute_path]] = unit if unit[:absolute_path]
|
|
118
|
+
index[unit[:real_path]] = unit if unit[:real_path]
|
|
118
119
|
end
|
|
119
120
|
end
|
|
120
121
|
|
|
@@ -136,6 +137,17 @@ module Branchproof
|
|
|
136
137
|
unit[:compile_options] || {}
|
|
137
138
|
end
|
|
138
139
|
|
|
140
|
+
# The instrumenter already compiled the rewritten source once to validate it.
|
|
141
|
+
# Reuse that instruction sequence instead of compiling again, but only when
|
|
142
|
+
# it was built with the exact file/path/options this loader would use:
|
|
143
|
+
# real_path present and equal to absolute_path (so the instrumenter's file
|
|
144
|
+
# and path arguments match what we pass below), and no custom compile
|
|
145
|
+
# options (the instrumenter compiles without any).
|
|
146
|
+
def reusable_iseq?(unit, rewritten)
|
|
147
|
+
rewritten[:iseq] && compile_options(unit).empty? &&
|
|
148
|
+
unit[:real_path] && unit[:absolute_path] == unit[:real_path]
|
|
149
|
+
end
|
|
150
|
+
|
|
139
151
|
def status(state, reason)
|
|
140
152
|
{ status: state.to_s, reason: reason&.to_s }.freeze
|
|
141
153
|
end
|
|
@@ -140,15 +140,18 @@ module Branchproof
|
|
|
140
140
|
end
|
|
141
141
|
|
|
142
142
|
def greedy(ids, candidates, target)
|
|
143
|
+
positions = ids.each_with_index.to_h
|
|
143
144
|
chosen = []
|
|
145
|
+
chosen_set = Set.new
|
|
144
146
|
covered = Set.new
|
|
145
147
|
until covered >= target
|
|
146
|
-
available = ids.reject { |candidate|
|
|
147
|
-
id = available.max_by { |candidate| [(candidates[candidate] - covered).length, -
|
|
148
|
+
available = ids.reject { |candidate| chosen_set.include?(candidate) }
|
|
149
|
+
id = available.max_by { |candidate| [(candidates[candidate] - covered).length, -positions[candidate]] }
|
|
148
150
|
break unless id
|
|
149
151
|
break if (candidates[id] - covered).empty?
|
|
150
152
|
|
|
151
153
|
chosen << id
|
|
154
|
+
chosen_set << id
|
|
152
155
|
covered |= candidates[id]
|
|
153
156
|
end
|
|
154
157
|
chosen.sort.reverse_each do |candidate|
|
|
@@ -159,16 +162,20 @@ module Branchproof
|
|
|
159
162
|
chosen.sort
|
|
160
163
|
end
|
|
161
164
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
interchangeable = candidates.keys.select do |candidate|
|
|
168
|
-
candidates.fetch(candidate).intersect?(target) && candidates.reject do |key, _|
|
|
169
|
-
key == candidate
|
|
170
|
-
end.values.reduce(Set.new, :|) >= target
|
|
165
|
+
# For each target obligation, how many candidates cover it. An obligation
|
|
166
|
+
# covered by exactly one candidate makes that candidate irreplaceable.
|
|
167
|
+
def coverage_counts(candidates)
|
|
168
|
+
candidates.each_value.with_object(Hash.new(0)) do |signs, counts|
|
|
169
|
+
signs.each { |obligation| counts[obligation] += 1 }
|
|
171
170
|
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def result(objective, scope, target, selected, status, lower, visited, reasons, candidates = {})
|
|
174
|
+
counts = coverage_counts(candidates)
|
|
175
|
+
necessary = candidates.select { |_, signs| signs.any? { |obligation| counts[obligation] == 1 } }.keys
|
|
176
|
+
interchangeable = candidates.select do |_, signs|
|
|
177
|
+
!signs.empty? && signs.none? { |obligation| counts[obligation] == 1 }
|
|
178
|
+
end.keys
|
|
172
179
|
{ objective: objective, scope_decision_ids: scope,
|
|
173
180
|
target_obligations: target.to_a.sort_by do |decision, index, sign|
|
|
174
181
|
[decision.to_s, index.to_i, sign ? 1 : 0]
|
|
@@ -189,8 +196,6 @@ module Branchproof
|
|
|
189
196
|
value&.to_i&.positive? ? value.to_i : default
|
|
190
197
|
end
|
|
191
198
|
|
|
192
|
-
def covers?(set, target) = set >= target
|
|
193
|
-
|
|
194
199
|
def value(vector, index) = (vector[:values] || vector["values"])[index]
|
|
195
200
|
def id(record, key) = record[key].nil? ? record[key.to_s] : record[key]
|
|
196
201
|
def records(record, key) = Array(id(record, key))
|
|
@@ -13,10 +13,6 @@ module Branchproof
|
|
|
13
13
|
@tests = {}
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
-
def capabilities
|
|
17
|
-
{ serial: true, phases: true }.freeze
|
|
18
|
-
end
|
|
19
|
-
|
|
20
16
|
def run(test_files:, runner_args:, on_complete:, before_load: nil)
|
|
21
17
|
raise ArgumentError, "test_files must be an Array" unless test_files.is_a?(Array)
|
|
22
18
|
raise ArgumentError, "on_complete must respond to call" unless on_complete.respond_to?(:call)
|
|
@@ -158,12 +154,7 @@ module Branchproof
|
|
|
158
154
|
record = @tests[test_id]
|
|
159
155
|
return unless record
|
|
160
156
|
|
|
161
|
-
|
|
162
|
-
(failure.respond_to?(:skipped?) && failure.skipped?) ||
|
|
163
|
-
(defined?(Minitest::Skip) && failure.respond_to?(:error) && failure.error.is_a?(Minitest::Skip)) ||
|
|
164
|
-
failure.class.name.to_s.include?("Skip")
|
|
165
|
-
end
|
|
166
|
-
record[:status] = if skipped
|
|
157
|
+
record[:status] = if test.skipped?
|
|
167
158
|
"skipped"
|
|
168
159
|
else
|
|
169
160
|
(test.failures.empty? ? "passed" : "failed")
|
|
@@ -182,17 +173,21 @@ module Branchproof
|
|
|
182
173
|
end
|
|
183
174
|
|
|
184
175
|
def test_id_for(test)
|
|
176
|
+
return test.instance_variable_get(:@branchproof_test_id) if test.instance_variable_defined?(:@branchproof_test_id)
|
|
177
|
+
|
|
185
178
|
source = begin
|
|
186
179
|
test.method(test.name).source_location
|
|
187
180
|
rescue NameError
|
|
188
181
|
nil
|
|
189
182
|
end
|
|
190
|
-
if defined?(Branchproof::Records)
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
183
|
+
id = if defined?(Branchproof::Records)
|
|
184
|
+
Branchproof::Records.id(adapter: "minitest", class_name: test.class.name, method_name: test.name,
|
|
185
|
+
source: source)
|
|
186
|
+
else
|
|
187
|
+
"minitest:#{test.class}:#{test.name}:#{source}"
|
|
188
|
+
end
|
|
189
|
+
test.instance_variable_set(:@branchproof_test_id, id)
|
|
190
|
+
id
|
|
196
191
|
end
|
|
197
192
|
|
|
198
193
|
def register_test(test)
|