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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +35 -0
  3. data/README.md +212 -11
  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/FocusedReport.md +5 -2
  13. data/doc/Branchproof/Loader.md +0 -4
  14. data/doc/Branchproof/MinitestAdapter.md +0 -3
  15. data/doc/Branchproof/Report.md +18 -0
  16. data/doc/Branchproof/Runtime.md +4 -0
  17. data/doc/Branchproof/SavedReport.md +15 -0
  18. data/doc/Branchproof.md +4 -1
  19. data/doc/CHANGELOG.md +35 -0
  20. data/doc/README.md +212 -11
  21. data/lib/branchproof/analyzer.rb +80 -44
  22. data/lib/branchproof/cli.rb +28 -19
  23. data/lib/branchproof/comparison.rb +207 -19
  24. data/lib/branchproof/comparison_report.rb +49 -1
  25. data/lib/branchproof/constraints.rb +363 -0
  26. data/lib/branchproof/coverage_index.rb +30 -1
  27. data/lib/branchproof/decision_syntax.rb +31 -15
  28. data/lib/branchproof/decision_table.rb +377 -0
  29. data/lib/branchproof/evidence.rb +51 -31
  30. data/lib/branchproof/flow_instrumentation.rb +17 -17
  31. data/lib/branchproof/focused_report.rb +77 -9
  32. data/lib/branchproof/instrumenter.rb +45 -34
  33. data/lib/branchproof/limits.rb +4 -1
  34. data/lib/branchproof/loader.rb +18 -6
  35. data/lib/branchproof/minimizer.rb +18 -13
  36. data/lib/branchproof/minitest_adapter.rb +11 -16
  37. data/lib/branchproof/records.rb +2 -0
  38. data/lib/branchproof/report.rb +301 -68
  39. data/lib/branchproof/runtime.rb +22 -30
  40. data/lib/branchproof/saved_report.rb +200 -14
  41. data/lib/branchproof/source.rb +90 -48
  42. data/lib/branchproof/version.rb +1 -1
  43. data/lib/branchproof/worker.rb +1 -4
  44. data/lib/branchproof.rb +2 -0
  45. data/llms.txt +4 -1
  46. data/sig/branchproof.rbs +47 -1
  47. 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
- 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
 
@@ -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
- unless supported_unit?(unit, reasons)
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 decisions.any? do |outer|
24
- encloses_decision?(outer, decision)
25
- end
22
+ next if enclosed[decision]
26
23
 
27
- decision_edit(bytes, decision, decisions)
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
- unit[:real_path] || unit[:absolute_path] || "(branchproof)", 1)
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(bytes, diagnostics: [diagnostic("invalid_rewrite", e.message)])
37
+ return result(rewritten, diagnostics: [diagnostic("invalid_rewrite", e.message)])
41
38
  end
42
- result(rewritten.force_encoding(unit[:original_bytes].encoding), changed: rewritten != bytes)
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 supported_unit?(unit, _reasons)
48
- status = unit[:support_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
- def supported?(decision)
53
- status = decision[:support_status]
54
- status.nil? || status.to_s.casecmp("supported").zero?
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, all_decisions)
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 = all_decisions.select do |candidate|
63
- encloses_decision?(decision, candidate)
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, start, length, decision, nested)
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 = nested.select { |candidate| encloses_decision?(child, candidate) }
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[:byte_start], decision[:byte_length], decision, nested)
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] }.each_with_object(bytes.dup) do |edit, output|
138
- output[edit[:start]...edit[:finish]] = edit[:text]
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)
@@ -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
@@ -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
- @units.keys.any? { |path| $LOADED_FEATURES.any? { |feature| canonical(feature) == path } }
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
- path = unit[:absolute_path] || unit[:real_path]
117
- index[canonical(path)] = unit if path
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| chosen.include?(candidate) }
147
- id = available.max_by { |candidate| [(candidates[candidate] - covered).length, -ids.index(candidate)] }
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
- def result(objective, scope, target, selected, status, lower, visited, reasons, candidates = {})
163
- necessary = candidates.keys.reject do |candidate|
164
- others = candidates.reject { |key, _| key == candidate }.values.reduce(Set.new, :|)
165
- covers?(others, target)
166
- end
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
- skipped = test.failures.any? do |failure|
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
- Branchproof::Records.id(adapter: "minitest", class_name: test.class.name, method_name: test.name,
192
- source: source)
193
- else
194
- "minitest:#{test.class}:#{test.name}:#{source}"
195
- end
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)
@@ -43,6 +43,8 @@ module Branchproof
43
43
  end
44
44
 
45
45
  def deep_freeze(value)
46
+ return value if value.frozen?
47
+
46
48
  case value
47
49
  when Hash
48
50
  value.transform_values { |item| deep_freeze(item) }.freeze