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
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Branchproof
4
+ # Observe entry into source-visible callback bodies, not library internals.
5
+ module IterationSyntax
6
+ ITERATORS = %i[each each_with_index times upto downto step map collect select filter reject filter_map
7
+ find detect find_index take_while drop_while partition count all? any? none? one? fetch].freeze
8
+
9
+ private
10
+
11
+ def flow_decision_node?(node)
12
+ iteration_node?(node) || node.is_a?(Prism::MatchRequiredNode) || super
13
+ end
14
+
15
+ def iteration_node?(node)
16
+ node.is_a?(Prism::ForNode) ||
17
+ (node.is_a?(Prism::CallNode) && ITERATORS.include?(node.name) &&
18
+ node.block.is_a?(Prism::BlockNode) && !node.safe_navigation?)
19
+ end
20
+
21
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
22
+ def flow_details(node, bytes, encoding)
23
+ if node.is_a?(Prism::MatchRequiredNode)
24
+ alternatives = %w[matched mismatch].map { |label| iteration_alternative(node, label) }
25
+ return ["pattern", "required_pattern", alternatives,
26
+ { type: "required_pattern", range: byte_range(node.location) }, unsupported_reasons(node, bytes)]
27
+ end
28
+ return super unless iteration_node?(node)
29
+
30
+ body = node.is_a?(Prism::ForNode) ? node.statements : node.block.body
31
+ closing = node.is_a?(Prism::ForNode) ? node.end_keyword_loc : node.block.closing_loc
32
+ lazy = node.is_a?(Prism::CallNode) && lazy_receiver?(node.receiver)
33
+ fallback = node.is_a?(Prism::CallNode) && node.name == :fetch
34
+ context = if lazy
35
+ "lazy_callback"
36
+ else
37
+ (fallback ? "fetch_fallback" : "iteration")
38
+ end
39
+ labels = if lazy
40
+ ["callback entered"]
41
+ else
42
+ (fallback ? ["value present", "fallback entered"] : %w[empty entered])
43
+ end
44
+ metadata = { type: "iteration", range: byte_range(node.location), lazy: lazy,
45
+ insert_at: iteration_insert_at(body, closing),
46
+ empty: body.nil? }
47
+ metadata[:receiver] = byte_range(node.receiver.location) if node.is_a?(Prism::CallNode) && node.receiver
48
+ [lazy ? "multiway" : "implicit", context, labels.map { |label| iteration_alternative(node, label) },
49
+ metadata, unsupported_reasons(node, bytes)]
50
+ end
51
+
52
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
53
+
54
+ def lazy_receiver?(node)
55
+ node.is_a?(Prism::CallNode) && (node.name == :lazy || lazy_receiver?(node.receiver))
56
+ end
57
+
58
+ # Prism gives a block-level rescue/ensure BeginNode a location beginning at
59
+ # the `do` keyword. Inserting there would produce `call; callback; do`, so
60
+ # anchor the callback after the block parameters and before its statements.
61
+ def iteration_insert_at(body, closing)
62
+ return closing.start_offset unless body
63
+
64
+ if body.is_a?(Prism::BeginNode)
65
+ statements = body.statements
66
+ return statements.location.start_offset if statements&.location
67
+ end
68
+ body.location.start_offset
69
+ end
70
+
71
+ def iteration_alternative(node, label)
72
+ { expression: label, byte_start: node.location.start_offset, byte_length: node.location.length }
73
+ end
74
+ end
75
+ end
@@ -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)
@@ -49,7 +47,12 @@ module Branchproof
49
47
  add_diagnostic("source_drift", "selected source changed after inventory", unit[:source_id])
50
48
  return nil
51
49
  end
52
- rewritten = @instrumenter.rewrite(unit: unit.merge(original_bytes: bytes))
50
+ begin
51
+ rewritten = @instrumenter.rewrite(unit: unit.merge(original_bytes: bytes))
52
+ rescue StandardError => e
53
+ add_diagnostic("rewrite_failure", "#{e.class}: #{e.message}", unit[:source_id], severity: "error")
54
+ return nil
55
+ end
53
56
  Array(rewritten[:diagnostics]).each do |diagnostic|
54
57
  add_diagnostic(diagnostic[:code], diagnostic[:message], unit[:source_id], severity: diagnostic[:severity])
55
58
  end
@@ -61,6 +64,8 @@ module Branchproof
61
64
  return nil
62
65
  end
63
66
 
67
+ return rewritten[:iseq] if reusable_iseq?(unit, rewritten)
68
+
64
69
  RubyVM::InstructionSequence.compile(
65
70
  rewritten[:bytes], unit[:real_path] || canonical(path), unit[:real_path] || canonical(path), 1,
66
71
  compile_options(unit)
@@ -108,13 +113,14 @@ module Branchproof
108
113
  end
109
114
 
110
115
  def preloaded_target?
111
- @units.keys.any? { |path| $LOADED_FEATURES.any? { |feature| canonical(feature) == path } }
116
+ loaded = $LOADED_FEATURES.each_with_object(Set.new) { |feature, set| set << canonical(feature) }
117
+ @units.keys.any? { |path| loaded.include?(canonical(path)) }
112
118
  end
113
119
 
114
120
  def index_units(inventory)
115
121
  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
122
+ index[unit[:absolute_path]] = unit if unit[:absolute_path]
123
+ index[unit[:real_path]] = unit if unit[:real_path]
118
124
  end
119
125
  end
120
126
 
@@ -136,6 +142,17 @@ module Branchproof
136
142
  unit[:compile_options] || {}
137
143
  end
138
144
 
145
+ # The instrumenter already compiled the rewritten source once to validate it.
146
+ # Reuse that instruction sequence instead of compiling again, but only when
147
+ # it was built with the exact file/path/options this loader would use:
148
+ # real_path present and equal to absolute_path (so the instrumenter's file
149
+ # and path arguments match what we pass below), and no custom compile
150
+ # options (the instrumenter compiles without any).
151
+ def reusable_iseq?(unit, rewritten)
152
+ rewritten[:iseq] && compile_options(unit).empty? &&
153
+ unit[:real_path] && unit[:absolute_path] == unit[:real_path]
154
+ end
155
+
139
156
  def status(state, reason)
140
157
  { status: state.to_s, reason: reason&.to_s }.freeze
141
158
  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