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
@@ -4,11 +4,23 @@ require "digest"
4
4
  require "pathname"
5
5
  require "prism"
6
6
  require_relative "decision_syntax"
7
+ require_relative "iteration_syntax"
8
+ require_relative "exception_syntax"
9
+ require_relative "default_syntax"
10
+ require_relative "value_syntax"
11
+ require_relative "constraints"
7
12
 
8
13
  module Branchproof
9
14
  # Inventories supported condition and decision occurrences from Ruby files.
10
15
  class Source
11
16
  include DecisionSyntax
17
+ # Contextual syntax modules are layered after the baseline classifier so
18
+ # each can extend discovery without coupling this walker to every syntax
19
+ # family. Additional modules can follow the same seam.
20
+ include IterationSyntax
21
+ include ExceptionSyntax
22
+ prepend DefaultSyntax
23
+ prepend ValueSyntax
12
24
 
13
25
  attr_reader :root, :limits
14
26
 
@@ -58,11 +70,13 @@ module Branchproof
58
70
  end
59
71
 
60
72
  def read_unit(path)
73
+ absolute_path = File.expand_path(path)
74
+ relative_path = relative(path)
61
75
  bytes = File.binread(path)
62
76
  parsed = Prism.parse(bytes)
63
77
  encoding = source_encoding(bytes, parsed)
64
- source_id = Records.source_id(relative_path: relative(path), digest: Digest::SHA256.hexdigest(bytes),
65
- encoding: encoding)
78
+ digest = Digest::SHA256.hexdigest(bytes)
79
+ source_id = Records.source_id(relative_path: relative_path, digest: digest, encoding: encoding)
66
80
  diagnostics = parsed.errors.map do |error|
67
81
  Records.diagnostic(code: "parse_error", severity: "error", message: error.message, source_id: source_id,
68
82
  details: { byte_start: error.location.start_offset, byte_length: error.location.length })
@@ -78,11 +92,11 @@ module Branchproof
78
92
  file_reasons << "unsupported_data_section" if parsed.respond_to?(:data_loc) && parsed.data_loc
79
93
  file_reasons << "parse_error" unless parsed.errors.empty?
80
94
  decisions = parsed.value ? decisions_for(parsed.value, bytes, source_id, file_reasons, encoding) : []
81
- Records.build(source_id: source_id, relative_path: relative(path), absolute_path: File.expand_path(path),
82
- real_path: File.realpath(path), digest: Digest::SHA256.hexdigest(bytes), encoding: encoding,
95
+ Records.build(source_id: source_id, relative_path: relative_path, absolute_path: absolute_path,
96
+ real_path: File.realpath(path), digest: digest, encoding: encoding,
83
97
  original_bytes: bytes, decisions: decisions, diagnostics: diagnostics)
84
98
  rescue SystemCallError => e
85
- Records.build(source_id: nil, relative_path: relative(path), absolute_path: File.expand_path(path),
99
+ Records.build(source_id: nil, relative_path: relative_path, absolute_path: absolute_path,
86
100
  real_path: nil, digest: nil, encoding: nil, original_bytes: nil, decisions: [],
87
101
  diagnostics: [Records.diagnostic(code: "source_unreadable", severity: "error", message: e.message)])
88
102
  end
@@ -100,25 +114,56 @@ module Branchproof
100
114
  Encoding::UTF_8.name
101
115
  end
102
116
 
117
+ # Walks the whole program AST exactly once, sorting every node into the
118
+ # buckets the two classification phases below need. Phase one (decision
119
+ # and case-when nodes) must run to completion before phase two (bare
120
+ # boolean/pattern nodes) because phase two skips nodes phase one already
121
+ # inventoried via mark_semantic_boolean_nodes.
122
+ def collect_ast_nodes(program)
123
+ defined_ranges = []
124
+ guard_patterns = {}.compare_by_identity
125
+ phase_one_nodes = []
126
+ phase_two_nodes = []
127
+ flow_nodes = []
128
+ value_nodes = []
129
+
130
+ walk_skipping_defined_operands(program) do |node|
131
+ if node.is_a?(Prism::DefinedNode)
132
+ defined_ranges << node.location
133
+ elsif node.is_a?(Prism::InNode)
134
+ pattern = node.pattern
135
+ guard_patterns[pattern] = true if pattern.is_a?(Prism::IfNode) || pattern.is_a?(Prism::UnlessNode)
136
+ end
137
+ phase_one_nodes << node if decision_node?(node) || subjectless_case?(node)
138
+ phase_two_nodes << node if boolean_node?(node) || node.is_a?(Prism::MatchPredicateNode) ||
139
+ node.is_a?(Prism::DefinedNode)
140
+ flow_nodes << node if flow_decision_node?(node)
141
+ value_nodes << node if respond_to?(:value_candidate_node?, true) && value_candidate_node?(node)
142
+ end
143
+
144
+ { defined_ranges: defined_ranges, guard_patterns: guard_patterns, phase_one_nodes: phase_one_nodes,
145
+ phase_two_nodes: phase_two_nodes, flow_nodes: flow_nodes, value_nodes: value_nodes }
146
+ end
147
+
103
148
  def decisions_for(program, bytes, source_id, file_reasons = [], encoding = "UTF-8")
104
149
  specs = []
105
150
  inventoried_boolean_nodes = {}.compare_by_identity
106
- defined_ranges = []
107
- pattern_guard_nodes = pattern_guard_nodes_for(program)
151
+ collected = collect_ast_nodes(program)
152
+ defined_ranges = collected[:defined_ranges]
153
+ guard_patterns = collected[:guard_patterns]
108
154
 
109
- walk(program) do |node|
110
- defined_ranges << node.location if node.is_a?(Prism::DefinedNode)
155
+ collected[:phase_one_nodes].each do |node|
111
156
  if decision_node?(node)
112
157
  predicate = node.predicate
113
158
  next unless predicate
114
159
 
115
160
  predicate = unwrap_predicate(predicate)
116
- context = pattern_guard_nodes[node] ? "pattern_guard" : decision_context(node, bytes)
161
+ context = guard_patterns[node] ? "pattern_guard" : decision_context(node, bytes)
117
162
  specs << { node: node, predicate: predicate, context: context }
118
163
  mark_semantic_boolean_nodes(predicate, inventoried_boolean_nodes)
119
- elsif subjectless_case?(node)
120
- when_nodes(node).each do |when_node|
121
- when_predicates(when_node).each do |predicate|
164
+ else
165
+ conditions_for(node).each do |when_node|
166
+ conditions_for(when_node).each do |predicate|
122
167
  predicate = unwrap_predicate(predicate)
123
168
  specs << { node: when_node, predicate: predicate, context: "case_when" }
124
169
  mark_semantic_boolean_nodes(predicate, inventoried_boolean_nodes)
@@ -129,18 +174,19 @@ module Branchproof
129
174
 
130
175
  # Boolean expressions nested in an atomic expression (for example, a call
131
176
  # argument) are separate decisions when tree_for did not decompose them.
132
- walk(program) do |node|
133
- next unless boolean_node?(node) || node.is_a?(Prism::MatchPredicateNode)
177
+ collected[:phase_two_nodes].each do |node|
134
178
  next if inventoried_boolean_nodes[node]
135
-
136
- additional_reasons = if within_defined_expression?(node, defined_ranges)
137
- ["unsupported_defined_expression"]
138
- else
139
- []
140
- end
141
- context = node.is_a?(Prism::MatchPredicateNode) ? "pattern_in" : "short_circuit"
179
+ next if within_defined_expression?(node, defined_ranges) && !node.is_a?(Prism::DefinedNode)
180
+
181
+ context = if node.is_a?(Prism::MatchPredicateNode)
182
+ "pattern_in"
183
+ elsif node.is_a?(Prism::DefinedNode)
184
+ "defined"
185
+ else
186
+ "short_circuit"
187
+ end
142
188
  specs << { node: nil, predicate: node, context: context,
143
- additional_reasons: additional_reasons }
189
+ additional_reasons: [] }
144
190
  mark_semantic_boolean_nodes(node, inventoried_boolean_nodes)
145
191
  end
146
192
 
@@ -158,42 +204,35 @@ module Branchproof
158
204
  predicate: predicate, context: spec[:context],
159
205
  additional_reasons: spec[:additional_reasons] || [])
160
206
  end
161
- boolean_decisions + flow_decisions_for(program, bytes, source_id, file_reasons, encoding)
207
+ decisions = boolean_decisions + flow_decisions_for(program, bytes, source_id, file_reasons, encoding,
208
+ defined_ranges: defined_ranges,
209
+ nodes: collected[:flow_nodes])
210
+ decisions + additional_decisions_for(program, bytes, source_id, file_reasons, encoding,
211
+ collected: collected, decisions: decisions)
162
212
  end
163
213
 
164
- def flow_decisions_for(program, bytes, source_id, file_reasons = [], encoding = "UTF-8")
165
- defined_ranges = []
166
- walk(program) do |node|
167
- defined_ranges << node.location if node.is_a?(Prism::DefinedNode)
168
- end
214
+ def additional_decisions_for(_program, _bytes, _source_id, _file_reasons, _encoding, **_options)
215
+ []
216
+ end
169
217
 
170
- super.map do |decision|
171
- next decision unless range_within_defined_expression?(decision, defined_ranges)
218
+ def flow_decisions_for(program, bytes, source_id, file_reasons = [], encoding = "UTF-8",
219
+ defined_ranges: nil, nodes: nil)
220
+ defined_ranges ||= collect_defined_ranges(program)
172
221
 
173
- decision.merge(
174
- support_status: "UNSUPPORTED",
175
- support_reasons: (Array(decision[:support_reasons]) + ["unsupported_defined_expression"]).uniq
176
- )
177
- end
222
+ nodes ||= collect_flow_decision_nodes(program)
223
+ nodes = nodes.reject { |node| within_defined_expression?(node, defined_ranges) }
224
+ super(program, bytes, source_id, file_reasons, encoding, nodes: nodes)
178
225
  end
179
226
 
180
- def pattern_guard_nodes_for(program)
181
- guards = {}.compare_by_identity
182
- walk(program) do |node|
183
- next unless node.is_a?(Prism::InNode)
184
-
185
- pattern = node.pattern
186
- guards[pattern] = true if pattern.is_a?(Prism::IfNode) || pattern.is_a?(Prism::UnlessNode)
187
- end
188
- guards
227
+ def collect_defined_ranges(program)
228
+ defined_ranges = []
229
+ walk(program) { |node| defined_ranges << node.location if node.is_a?(Prism::DefinedNode) }
230
+ defined_ranges
189
231
  end
190
232
 
191
233
  def range_within_defined_expression?(decision, defined_ranges)
192
234
  start_offset = decision[:byte_start]
193
- end_offset = start_offset + decision[:byte_length]
194
- defined_ranges.any? do |defined_location|
195
- defined_location.start_offset <= start_offset && defined_location.end_offset >= end_offset
196
- end
235
+ offsets_within_defined_expression?(start_offset, start_offset + decision[:byte_length], defined_ranges)
197
236
  end
198
237
 
199
238
  def walk(node, &block)
@@ -214,16 +253,7 @@ module Branchproof
214
253
  node.is_a?(Prism::CaseNode) && node.predicate.nil?
215
254
  end
216
255
 
217
- def when_nodes(node)
218
- conditions = node.conditions
219
- if conditions.is_a?(Array)
220
- conditions
221
- else
222
- (conditions.respond_to?(:body) ? conditions.body : [])
223
- end
224
- end
225
-
226
- def when_predicates(node)
256
+ def conditions_for(node)
227
257
  conditions = node.conditions
228
258
  if conditions.is_a?(Array)
229
259
  conditions
@@ -249,6 +279,7 @@ module Branchproof
249
279
  context ||= decision_context(node, bytes)
250
280
  leaves = []
251
281
  tree = tree_for(predicate, bytes, leaves)
282
+ decision_constraint_safe = constraint_safe_expression?(predicate)
252
283
  start_offset = predicate.location.start_offset
253
284
  length = predicate.location.length
254
285
  opaque_ranges = leaves.filter_map { |leaf| leaf.delete(:_opaque_range) }
@@ -256,9 +287,16 @@ module Branchproof
256
287
  expression = text_value(leaf.delete(:_expression), "UTF-8")
257
288
  location = leaf.delete(:_location)
258
289
  literal_truth = leaf.delete(:_literal_truth)
259
- Records.build(id: nil, index: index, byte_start: location.start_offset, byte_length: location.length,
260
- line: location.start_line, column: location.start_column,
261
- expression: expression, literal_truth: literal_truth, coupling: "unknown")
290
+ constraint = leaf.delete(:_constraint)
291
+ constraint_safe = leaf.delete(:_constraint_safe)
292
+ contextual = leaf.delete(:_contextual)
293
+ condition = Records.build(id: nil, index: index, byte_start: location.start_offset,
294
+ byte_length: location.length,
295
+ line: location.start_line, column: location.start_column,
296
+ expression: expression, literal_truth: literal_truth, coupling: "unknown",
297
+ constraint: constraint,
298
+ constraint_safe: decision_constraint_safe && constraint_safe == true)
299
+ contextual ? condition.merge(contextual: contextual) : condition
262
300
  end
263
301
  decision_id = Records.decision_id(source_id: source_id, context: context, byte_start: start_offset,
264
302
  byte_length: length, tree: tree)
@@ -287,9 +325,12 @@ module Branchproof
287
325
 
288
326
  def within_defined_expression?(node, defined_ranges)
289
327
  location = node.location
328
+ offsets_within_defined_expression?(location.start_offset, location.end_offset, defined_ranges)
329
+ end
330
+
331
+ def offsets_within_defined_expression?(start_offset, end_offset, defined_ranges)
290
332
  defined_ranges.any? do |defined_location|
291
- defined_location.start_offset <= location.start_offset &&
292
- defined_location.end_offset >= location.end_offset
333
+ defined_location.start_offset <= start_offset && defined_location.end_offset >= end_offset
293
334
  end
294
335
  end
295
336
 
@@ -300,7 +341,7 @@ module Branchproof
300
341
  inventoried_boolean_nodes[node] = true
301
342
  mark_semantic_boolean_nodes(node.left, inventoried_boolean_nodes)
302
343
  mark_semantic_boolean_nodes(node.right, inventoried_boolean_nodes)
303
- when Prism::MatchPredicateNode
344
+ when Prism::DefinedNode, Prism::MatchPredicateNode
304
345
  inventoried_boolean_nodes[node] = true
305
346
  when Prism::CallNode
306
347
  mark_semantic_boolean_nodes(node.receiver, inventoried_boolean_nodes) if unary_not?(node)
@@ -330,14 +371,40 @@ module Branchproof
330
371
  leaf = {
331
372
  _expression: bytes.byteslice(location.start_offset, location.length), _location: location,
332
373
  _literal_truth: literal_truth(node),
374
+ _constraint: (constraint = Constraints.for_node(node)),
375
+ _constraint_safe: safe_constraint_node?(constraint),
333
376
  _opaque_range: if node.is_a?(Prism::CallNode) && node.name == :!
334
377
  { start: location.start_offset, length: location.length }
335
378
  end
336
379
  }
380
+ leaf[:_contextual] = "implicit_regexp" if node.is_a?(Prism::MatchLastLineNode) ||
381
+ node.is_a?(Prism::InterpolatedMatchLastLineNode)
382
+ leaf[:_contextual] = "flip_flop" if node.is_a?(Prism::FlipFlopNode)
337
383
  leaves << leaf
338
384
  Records.build(type: :atom, index: leaves.length - 1)
339
385
  end
340
386
 
387
+ # Source-derived comparisons are deliberately untrusted. Ruby permits
388
+ # mutation between leaves and user-defined operators, so only repeated
389
+ # truthiness reads of local variables in an entirely side-effect-free
390
+ # decision may be used by the table solver.
391
+ def constraint_safe_expression?(node)
392
+ node = unwrap_predicate(node)
393
+ case node
394
+ when Prism::AndNode, Prism::OrNode
395
+ constraint_safe_expression?(node.left) && constraint_safe_expression?(node.right)
396
+ when Prism::LocalVariableReadNode, Prism::TrueNode, Prism::FalseNode, Prism::NilNode,
397
+ Prism::IntegerNode, Prism::FloatNode, Prism::SymbolNode, Prism::StringNode
398
+ true
399
+ else
400
+ false
401
+ end
402
+ end
403
+
404
+ def safe_constraint_node?(constraint)
405
+ constraint && constraint[:operator] == "truthy" && constraint[:subject][:kind] == "local"
406
+ end
407
+
341
408
  def unary_not?(node)
342
409
  node.is_a?(Prism::CallNode) && node.name == :! && node.receiver && node.call_operator_loc.nil?
343
410
  end
@@ -351,12 +418,7 @@ module Branchproof
351
418
 
352
419
  def unsupported_reasons(predicate, bytes)
353
420
  reasons = []
354
- walk(predicate) do |node|
355
- if node.is_a?(Prism::MatchLastLineNode) || node.is_a?(Prism::InterpolatedMatchLastLineNode)
356
- reasons << "unsupported_implicit_regexp"
357
- end
358
- reasons << "unsupported_flip_flop" if node.is_a?(Prism::FlipFlopNode)
359
- reasons << "unsupported_defined_expression" if node.is_a?(Prism::DefinedNode)
421
+ walk_skipping_defined_operands(predicate) do |node|
360
422
  reasons << "unsupported_heredoc" if node.respond_to?(:opening_loc) && node.opening_loc &&
361
423
  bytes.byteslice(node.opening_loc.start_offset,
362
424
  node.opening_loc.length).start_with?("<<")
@@ -364,6 +426,13 @@ module Branchproof
364
426
  reasons
365
427
  end
366
428
 
429
+ def walk_skipping_defined_operands(node, &block)
430
+ yield node
431
+ return if node.is_a?(Prism::DefinedNode)
432
+
433
+ node.child_nodes.each { |child| walk_skipping_defined_operands(child, &block) if child }
434
+ end
435
+
367
436
  def unwrap_predicate(node)
368
437
  while node.is_a?(Prism::ParenthesesNode)
369
438
  statements = node.body
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Branchproof
4
+ # Source rewrite for value observations; it preserves the wrapped value.
5
+ module ValueInstrumentation
6
+ private
7
+
8
+ def render_flow(bytes, decision, nested, encloses)
9
+ return render_value(bytes, decision, nested, encloses) if decision.dig(:instrumentation, :type) == "value"
10
+
11
+ super
12
+ end
13
+
14
+ # rubocop:disable-next Metrics/MethodLength -- success and exception traces differ.
15
+ def render_value(bytes, decision, nested, encloses)
16
+ expression = render_children(bytes, decision[:byte_start], decision[:byte_length], nested, encloses)
17
+ metadata = decision.fetch(:instrumentation)
18
+ runtime = self.class::RUNTIME
19
+ id = decision.fetch(:id).inspect
20
+ domain = metadata.fetch(:domain).to_sym.inspect
21
+ count = decision.fetch(:alternatives).length
22
+ enter = "#{runtime}.enter(#{id}); #{runtime}.set_alternative_count(#{id}, #{count})"
23
+ if metadata.fetch(:domain).to_sym == :dispatch
24
+ "(begin; #{enter}; begin; " \
25
+ "(begin; #{runtime}.dispatch_path(#{id}, (#{expression}), false); end); " \
26
+ "rescue ::Exception; #{runtime}.dispatch_path(#{id}, nil, true); raise; " \
27
+ "ensure; #{runtime}.leave(#{id}); end; end)"
28
+ else
29
+ "(begin; #{enter}; begin; " \
30
+ "#{runtime}.value_path(#{id}, (#{expression}), #{domain}); " \
31
+ "ensure; #{runtime}.leave(#{id}); end; end)"
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Branchproof
4
+ # Runtime mapping for value observations.
5
+ module ValueRuntime
6
+ # rubocop:disable Style/CaseEquality, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
7
+ def comparison_index(value)
8
+ return 3 if nil.equal?(value)
9
+ return unless Integer === value || Float === value || Rational === value
10
+ return if Float === value && value.nan?
11
+ return 0 if value < 0 # rubocop:disable Style/NumericPredicate
12
+ return 1 if value == 0 # rubocop:disable Style/NumericPredicate
13
+ return 2 if value > 0 # rubocop:disable Style/NumericPredicate
14
+
15
+ nil
16
+ end
17
+ module_function :comparison_index
18
+ # rubocop:enable Style/CaseEquality, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
19
+
20
+ def set_alternative_count(decision_id, count)
21
+ frame = current_frame(decision_id)
22
+ frame[:alternative_count] = count if frame
23
+ nil
24
+ end
25
+
26
+ # rubocop:disable-next Metrics/MethodLength -- trace state branches are explicit.
27
+ # rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- domain dispatch mirrors the observable value contract.
28
+ def value_path(decision_id, value, domain)
29
+ frame = current_frame(decision_id)
30
+ return value unless frame
31
+ return value if frame[:finished]
32
+
33
+ index = case domain.to_sym
34
+ when :truthiness, :defined then value ? 1 : 0
35
+ when :lookup
36
+ if nil.equal?(value)
37
+ 2
38
+ elsif value
39
+ 0
40
+ else
41
+ 1
42
+ end
43
+ when :comparison then comparison_index(value)
44
+ when :dispatch then 0
45
+ end
46
+ if index
47
+ frame[:observations] = if frame.fetch(:alternative_count, index + 1) == 2
48
+ [[0, index.zero?], [1, index == 1]]
49
+ else
50
+ (0..index).map { |position| [position, position == index] }
51
+ end
52
+ frame[:outcome] = true
53
+ frame[:finished] = true
54
+ end
55
+ value
56
+ rescue NoMethodError, ArgumentError
57
+ # A user-defined <=> may return an incomparable object. Preserve it and
58
+ # leave the vector incomplete instead of inventing a domain bucket.
59
+ value
60
+ end
61
+
62
+ def dispatch_path(decision_id, value, raised)
63
+ frame = current_frame(decision_id)
64
+ return value unless frame
65
+
66
+ index = raised ? 1 : 0
67
+ frame[:observations] = [[0, index.zero?], [1, index == 1]]
68
+ frame[:outcome] = true
69
+ frame[:finished] = true
70
+ value
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Branchproof
6
+ # Inventory for expressions whose result is itself the observable value.
7
+ # This deliberately excludes control-flow predicates already owned by Source.
8
+ module ValueSyntax
9
+ BOOLEAN_OPERATORS = %i[== != < <= > >= === =~ !~].freeze
10
+ DISPATCH_METHODS = %i[public_send send __send__].freeze
11
+
12
+ # rubocop:disable-next Metrics/ParameterLists -- source seam mirrors Source#decisions_for.
13
+ def additional_decisions_for(program, bytes, source_id, file_reasons, encoding, decisions:, collected:)
14
+ occupied = decisions.each_with_object({}) do |decision, ranges|
15
+ ranges[range_key(decision[:byte_start], decision[:byte_length])] = true
16
+ Array(decision[:conditions]).each do |condition|
17
+ ranges[range_key(condition[:byte_start], condition[:byte_length])] = true
18
+ end
19
+ end
20
+ value_decisions_for(
21
+ program, bytes, source_id, nodes: collected.fetch(:value_nodes), occupied_ranges: occupied,
22
+ file_reasons: file_reasons, encoding: encoding
23
+ )
24
+ end
25
+
26
+ # `nodes:` is supplied by Source's fused AST walk. It remains optional for
27
+ # the standalone discovery API used by focused syntax tests.
28
+ # rubocop:disable-next Metrics/MethodLength, Metrics/ParameterLists -- source seam mirrors Source#decisions_for.
29
+ def value_decisions_for(program, bytes, source_id, nodes: nil, occupied_ranges: {}, file_reasons: [],
30
+ encoding: "UTF-8")
31
+ occupied = occupied_ranges.transform_keys do |range|
32
+ range.is_a?(Array) ? range_key(*range) : range
33
+ end
34
+ decisions = []
35
+ each_value_node(program, nodes) do |node|
36
+ spec = value_spec(node)
37
+ next unless spec
38
+
39
+ key = range_key(node.location.start_offset, node.location.length)
40
+ next if occupied[key]
41
+
42
+ decisions << build_value_decision(node, spec, bytes, source_id, file_reasons, encoding)
43
+ occupied[key] = true
44
+ end
45
+ decisions
46
+ end
47
+
48
+ private
49
+
50
+ def value_candidate_node?(node)
51
+ !value_spec(node).nil?
52
+ end
53
+
54
+ def each_value_node(program, nodes, &)
55
+ return nodes.each(&) if nodes
56
+
57
+ walker = respond_to?(:walk_skipping_defined_operands, true) ? :walk_skipping_defined_operands : :walk
58
+ send(walker, program, &)
59
+ end
60
+
61
+ def range_key(start_offset, length)
62
+ (start_offset << 32) | length
63
+ end
64
+
65
+ # rubocop:disable-next Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
66
+ def value_spec(node)
67
+ if Prism.const_defined?(:MatchWriteNode) && node.is_a?(Prism::MatchWriteNode)
68
+ return { context: "match_capture", domain: :truthiness, alternatives: %w[false true], type: "value" }
69
+ end
70
+ return nil unless node.is_a?(Prism::CallNode)
71
+
72
+ name = node.name.to_sym
73
+ if name == :<=>
74
+ return { context: "comparison", domain: :comparison, alternatives: %w[negative zero positive nil],
75
+ type: "value" }
76
+ end
77
+ return { context: "lookup", domain: :lookup, alternatives: %w[truthy false nil], type: "value" } if name == :[]
78
+ if DISPATCH_METHODS.include?(name)
79
+ return { context: "dispatch", domain: :dispatch, alternatives: %w[success exception],
80
+ type: "value" }
81
+ end
82
+ if BOOLEAN_OPERATORS.include?(name) || name.to_s.end_with?("?")
83
+ return { context: "predicate", domain: :truthiness, alternatives: %w[false true],
84
+ type: "value" }
85
+ end
86
+
87
+ nil
88
+ end
89
+
90
+ # rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/ParameterLists, Metrics/PerceivedComplexity -- record assembly is intentionally explicit.
91
+ def build_value_decision(node, spec, bytes, source_id, file_reasons, encoding)
92
+ location = node.location
93
+ expression = text_value(bytes.byteslice(location.start_offset, location.length), encoding)
94
+ alternatives = spec.fetch(:alternatives).each_with_index.map do |label, index|
95
+ { index: index, id: nil, expression: label, byte_start: location.start_offset, byte_length: location.length }
96
+ end
97
+ id = Records.decision_id(source_id: source_id, context: spec.fetch(:context),
98
+ byte_start: location.start_offset, byte_length: location.length,
99
+ tree: nil)
100
+ alternatives = alternatives.map { |item| item.merge(id: Records.condition_id(id, item.fetch(:index))) }
101
+ kind = alternatives.length == 2 ? "implicit" : "multiway"
102
+ reasons = Array(file_reasons)
103
+ reasons += Array(unsupported_reasons(node, bytes)) if respond_to?(:unsupported_reasons, true)
104
+ limit = @limits[:conditions_per_decision] if defined?(@limits) && @limits.respond_to?(:[])
105
+ reasons << "alternative_limit_exceeded" if limit && alternatives.length > limit
106
+ Records.build(id: id, source_id: source_id, kind: kind, context: spec.fetch(:context),
107
+ byte_start: location.start_offset, byte_length: location.length,
108
+ line: location.start_line, column: location.start_column,
109
+ expression: expression, tree: nil, conditions: [],
110
+ alternatives: alternatives, discovered_condition_count: 0,
111
+ support_status: reasons.empty? ? "SUPPORTED" : "UNSUPPORTED",
112
+ support_reasons: reasons.uniq, opaque_ranges: [],
113
+ instrumentation: { type: "value", domain: spec.fetch(:domain) })
114
+ end
115
+ end
116
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Branchproof
4
- VERSION = "0.7.0"
4
+ VERSION = "0.9.0"
5
5
  end
@@ -14,12 +14,9 @@ module Branchproof
14
14
 
15
15
  def child_process(config_path)
16
16
  payload = JSON.parse(File.binread(config_path))
17
- project = symbolize(payload.fetch("project", legacy_project))
17
+ project = symbolize(payload.fetch("project"))
18
18
  prepend_load_paths(project)
19
19
  inventory = symbolize(payload.fetch("inventory"))
20
- Array(inventory[:source_units]).each do |unit|
21
- unit[:original_bytes] = File.binread(unit[:absolute_path]) if unit[:absolute_path]
22
- end
23
20
  limits = symbolize(payload.fetch("limits"))
24
21
  require "minitest"
25
22
  require "minitest/test"
data/lib/branchproof.rb CHANGED
@@ -16,6 +16,8 @@ module Branchproof
16
16
  autoload :MinitestAdapter, "branchproof/minitest_adapter"
17
17
  autoload :Evidence, "branchproof/evidence"
18
18
  autoload :Analyzer, "branchproof/analyzer"
19
+ autoload :Constraints, "branchproof/constraints"
20
+ autoload :DecisionTable, "branchproof/decision_table"
19
21
  autoload :Minimizer, "branchproof/minimizer"
20
22
  autoload :Report, "branchproof/report"
21
23
  autoload :CoverageIndex, "branchproof/coverage_index"
data/llms.txt CHANGED
@@ -2,10 +2,11 @@
2
2
 
3
3
  | | |
4
4
  | --- | --- |
5
- | **Defined in** | lib/branchproof.rb, lib/branchproof/cli.rb, lib/branchproof/limits.rb, lib/branchproof/loader.rb, lib/branchproof/report.rb, lib/branchproof/source.rb, lib/branchproof/worker.rb, lib/branchproof/project.rb, lib/branchproof/records.rb, lib/branchproof/runtime.rb, lib/branchproof/version.rb, lib/branchproof/analyzer.rb, lib/branchproof/evidence.rb, lib/branchproof/minimizer.rb, lib/branchproof/comparison.rb, lib/branchproof/instrumenter.rb, lib/branchproof/runtime_flow.rb, lib/branchproof/saved_report.rb, lib/branchproof/rails_support.rb, lib/branchproof/coverage_index.rb, lib/branchproof/focused_report.rb, lib/branchproof/decision_syntax.rb, lib/branchproof/minitest_adapter.rb, lib/branchproof/comparison_report.rb, lib/branchproof/flow_instrumentation.rb |
5
+ | **Defined in** | lib/branchproof.rb, lib/branchproof/cli.rb, lib/branchproof/limits.rb, lib/branchproof/loader.rb, lib/branchproof/report.rb, lib/branchproof/source.rb, lib/branchproof/worker.rb, lib/branchproof/project.rb, lib/branchproof/records.rb, lib/branchproof/runtime.rb, lib/branchproof/version.rb, lib/branchproof/analyzer.rb, lib/branchproof/evidence.rb, lib/branchproof/minimizer.rb, lib/branchproof/comparison.rb, lib/branchproof/constraints.rb, lib/branchproof/instrumenter.rb, lib/branchproof/runtime_flow.rb, lib/branchproof/saved_report.rb, lib/branchproof/value_syntax.rb, lib/branchproof/rails_support.rb, lib/branchproof/value_runtime.rb, lib/branchproof/coverage_index.rb, lib/branchproof/decision_table.rb, lib/branchproof/default_syntax.rb, lib/branchproof/focused_report.rb, lib/branchproof/decision_syntax.rb, lib/branchproof/default_runtime.rb, lib/branchproof/exception_syntax.rb, lib/branchproof/iteration_syntax.rb, lib/branchproof/minitest_adapter.rb, lib/branchproof/comparison_report.rb, lib/branchproof/exception_runtime.rb, lib/branchproof/iteration_runtime.rb, lib/branchproof/flow_instrumentation.rb, lib/branchproof/value_instrumentation.rb, lib/branchproof/default_instrumentation.rb, lib/branchproof/exception_instrumentation.rb, lib/branchproof/iteration_instrumentation.rb, lib/branchproof/extended_alternative_runtime.rb |
6
6
 
7
- Keep each bounded source rewrite together so its evaluation order can be
8
- audited. rubocop:disable Metrics/AbcSize, Metrics/MethodLength
7
+ Keep source-boundary and parameter-binding rules together for auditing.
8
+ rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity,
9
+ Metrics/MethodLength, Metrics/ModuleLength, Metrics/PerceivedComplexity
9
10
 
10
11
  ## Constants
11
12
  ### `VERSION` <a id="constant-VERSION"></a> <a id="VERSION-constant"></a>
@@ -17,13 +18,26 @@ Not documented.
17
18
  - [Branchproof/CLI.md](doc/Branchproof/CLI.md)
18
19
  - [Branchproof/Comparison.md](doc/Branchproof/Comparison.md)
19
20
  - [Branchproof/ComparisonReport.md](doc/Branchproof/ComparisonReport.md)
21
+ - [Branchproof/Constraints/Solver.md](doc/Branchproof/Constraints/Solver.md)
22
+ - [Branchproof/Constraints.md](doc/Branchproof/Constraints.md)
20
23
  - [Branchproof/CoverageIndex.md](doc/Branchproof/CoverageIndex.md)
21
24
  - [Branchproof/DecisionSyntax.md](doc/Branchproof/DecisionSyntax.md)
25
+ - [Branchproof/DecisionTable.md](doc/Branchproof/DecisionTable.md)
26
+ - [Branchproof/DefaultInstrumentation.md](doc/Branchproof/DefaultInstrumentation.md)
27
+ - [Branchproof/DefaultRuntime.md](doc/Branchproof/DefaultRuntime.md)
28
+ - [Branchproof/DefaultSyntax.md](doc/Branchproof/DefaultSyntax.md)
22
29
  - [Branchproof/Error.md](doc/Branchproof/Error.md)
23
30
  - [Branchproof/Evidence.md](doc/Branchproof/Evidence.md)
31
+ - [Branchproof/ExceptionInstrumentation.md](doc/Branchproof/ExceptionInstrumentation.md)
32
+ - [Branchproof/ExceptionRuntime.md](doc/Branchproof/ExceptionRuntime.md)
33
+ - [Branchproof/ExceptionSyntax.md](doc/Branchproof/ExceptionSyntax.md)
34
+ - [Branchproof/ExtendedAlternativeRuntime.md](doc/Branchproof/ExtendedAlternativeRuntime.md)
24
35
  - [Branchproof/FlowInstrumentation.md](doc/Branchproof/FlowInstrumentation.md)
25
36
  - [Branchproof/FocusedReport.md](doc/Branchproof/FocusedReport.md)
26
37
  - [Branchproof/Instrumenter.md](doc/Branchproof/Instrumenter.md)
38
+ - [Branchproof/IterationInstrumentation.md](doc/Branchproof/IterationInstrumentation.md)
39
+ - [Branchproof/IterationRuntime.md](doc/Branchproof/IterationRuntime.md)
40
+ - [Branchproof/IterationSyntax.md](doc/Branchproof/IterationSyntax.md)
27
41
  - [Branchproof/Limits.md](doc/Branchproof/Limits.md)
28
42
  - [Branchproof/Loader.md](doc/Branchproof/Loader.md)
29
43
  - [Branchproof/Minimizer.md](doc/Branchproof/Minimizer.md)
@@ -37,6 +51,9 @@ Not documented.
37
51
  - [Branchproof/RuntimeFlow.md](doc/Branchproof/RuntimeFlow.md)
38
52
  - [Branchproof/SavedReport.md](doc/Branchproof/SavedReport.md)
39
53
  - [Branchproof/Source.md](doc/Branchproof/Source.md)
54
+ - [Branchproof/ValueInstrumentation.md](doc/Branchproof/ValueInstrumentation.md)
55
+ - [Branchproof/ValueRuntime.md](doc/Branchproof/ValueRuntime.md)
56
+ - [Branchproof/ValueSyntax.md](doc/Branchproof/ValueSyntax.md)
40
57
  - [Branchproof/Worker.md](doc/Branchproof/Worker.md)
41
58
  - [CHANGELOG.md](doc/CHANGELOG.md)
42
59
  - [README.md](doc/README.md)