branchproof 0.2.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 (48) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +36 -0
  3. data/CODE_OF_CONDUCT.md +10 -0
  4. data/LICENSE.txt +202 -0
  5. data/NOTICE +3 -0
  6. data/README.md +242 -0
  7. data/doc/Branchproof/Analyzer.md +26 -0
  8. data/doc/Branchproof/CLI.md +15 -0
  9. data/doc/Branchproof/Error.md +6 -0
  10. data/doc/Branchproof/Evidence.md +46 -0
  11. data/doc/Branchproof/Instrumenter.md +18 -0
  12. data/doc/Branchproof/Limits.md +21 -0
  13. data/doc/Branchproof/Loader.md +25 -0
  14. data/doc/Branchproof/Minimizer.md +15 -0
  15. data/doc/Branchproof/MinitestAdapter.md +28 -0
  16. data/doc/Branchproof/Project.md +23 -0
  17. data/doc/Branchproof/RailsSupport/Error.md +6 -0
  18. data/doc/Branchproof/RailsSupport.md +32 -0
  19. data/doc/Branchproof/Records.md +38 -0
  20. data/doc/Branchproof/Report.md +26 -0
  21. data/doc/Branchproof/Runtime.md +37 -0
  22. data/doc/Branchproof/Source.md +23 -0
  23. data/doc/Branchproof/Worker.md +45 -0
  24. data/doc/Branchproof.md +33 -0
  25. data/doc/CHANGELOG.md +36 -0
  26. data/doc/README.md +242 -0
  27. data/exe/mcdc +6 -0
  28. data/lib/branchproof/analyzer.rb +454 -0
  29. data/lib/branchproof/cli.rb +266 -0
  30. data/lib/branchproof/evidence.rb +484 -0
  31. data/lib/branchproof/instrumenter.rb +150 -0
  32. data/lib/branchproof/limits.rb +44 -0
  33. data/lib/branchproof/loader.rb +140 -0
  34. data/lib/branchproof/minimizer.rb +198 -0
  35. data/lib/branchproof/minitest_adapter.rb +245 -0
  36. data/lib/branchproof/project.rb +53 -0
  37. data/lib/branchproof/rails_support.rb +74 -0
  38. data/lib/branchproof/records.rb +76 -0
  39. data/lib/branchproof/report.rb +412 -0
  40. data/lib/branchproof/runtime.rb +171 -0
  41. data/lib/branchproof/source.rb +238 -0
  42. data/lib/branchproof/version.rb +5 -0
  43. data/lib/branchproof/worker.rb +145 -0
  44. data/lib/branchproof.rb +24 -0
  45. data/lib/mcdc.rb +3 -0
  46. data/llms.txt +33 -0
  47. data/sig/branchproof.rbs +116 -0
  48. metadata +132 -0
@@ -0,0 +1,454 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Branchproof
6
+ # Performs the Boolean, occurrence-level masking analysis. It deliberately
7
+ # accepts plain records so the core does not depend on Minitest or Runtime.
8
+ class Analyzer
9
+ CRITERION = "masking_occurrence_v1"
10
+
11
+ def initialize(inventory:, evidence:, limits:)
12
+ @inventory = inventory || {}
13
+ @evidence = evidence || {}
14
+ @limits = limits || {}
15
+ @vectors = records(@evidence, :vectors)
16
+ @constraint_states = Hash.new(0)
17
+ @missing_cache = {}
18
+ @invalid_diagnostics = []
19
+ @analysis_invalid = false
20
+ @diagnostic_keys = Set.new
21
+ end
22
+
23
+ def call
24
+ decisions = records(@inventory, :decisions).map { analyze_decision(_1) }
25
+ proven = decisions.sum { |decision| decision[:condition_results].count { |result| result[:status] == "PROVEN" } }
26
+ eligible = decisions.sum { |decision| decision[:unsupported] ? 0 : decision[:conditions].length }
27
+ {
28
+ criterion_version: CRITERION,
29
+ decisions: decisions,
30
+ proven_count: proven,
31
+ eligible_count: eligible,
32
+ completeness: completeness(decisions),
33
+ diagnostics: decisions.flat_map { |decision| decision[:diagnostics] } + @invalid_diagnostics
34
+ }.freeze
35
+ end
36
+
37
+ def pair?(decision_id:, condition_index:, left:, right:)
38
+ return false unless compatible_vectors?(left, right, decision_id)
39
+ return false unless observed?(left, condition_index) && observed?(right, condition_index)
40
+ return false if value(left, condition_index) == value(right, condition_index)
41
+ return false if outcome(left) == outcome(right)
42
+
43
+ masks = [left, right].map { effective_mask(_1, decision_id) }
44
+ masks.all? { |mask| mask&.anybits?(1 << condition_index) }
45
+ end
46
+
47
+ def missing(decision_id:, condition_index:)
48
+ cache_key = [decision_id, condition_index]
49
+ return @missing_cache[cache_key] if @missing_cache.key?(cache_key)
50
+
51
+ decision = records(@inventory, :decisions).find { id(_1, :id) == decision_id }
52
+ return @missing_cache[cache_key] = nil unless decision
53
+
54
+ support_status = id(decision, :support_status).to_s
55
+ return @missing_cache[cache_key] = nil unless support_status.empty? || support_status.upcase == "SUPPORTED"
56
+
57
+ relevant = @vectors.select { id(_1, :decision_id) == decision_id }
58
+ valid = relevant.filter_map { valid_vector(_1, decision) }
59
+ if valid.empty?
60
+ return @missing_cache[cache_key] = {
61
+ status: "NO_OBSERVATIONS", constraints: [], candidate_vectors: [],
62
+ existing_vector_id: nil,
63
+ feasibility_statement: "No completed structural observation exists."
64
+ }
65
+ end
66
+ return @missing_cache[cache_key] = nil if first_pair(valid, condition_index, decision_id)
67
+
68
+ existing = valid.find { |vector| effective_mask(vector, decision_id).anybits?(1 << condition_index) }
69
+ signs = existing ? [!value(existing, condition_index)] : [true, false]
70
+ candidates = signs.filter_map { counterpart(decision, condition_index, _1) }
71
+ candidates.select! do |candidate|
72
+ !existing || pair?(decision_id: decision_id, condition_index: condition_index, left: existing,
73
+ right: candidate[:vector])
74
+ end
75
+ limited = @constraint_states[id(decision, :id)] > constraint_limit
76
+ if limited
77
+ @analysis_invalid = true
78
+ add_diagnostic("limit_reached", "error", nil, id(decision, :id))
79
+ end
80
+ @missing_cache[cache_key] = {
81
+ status: if limited
82
+ "LIMIT_REACHED"
83
+ else
84
+ (candidates.empty? ? "INFEASIBLE_IN_MODEL" : "CANDIDATE")
85
+ end,
86
+ constraints: candidates.flat_map { _1[:constraints] }.uniq,
87
+ candidate_vectors: candidates.map { _1[:vector] },
88
+ existing_vector_id: existing && id(existing, :id),
89
+ feasibility_statement: feasibility_statement(candidates)
90
+ }
91
+ end
92
+
93
+ private
94
+
95
+ def analyze_decision(decision)
96
+ decision_id = id(decision, :id)
97
+ unless id(decision, :support_status).to_s.empty? || id(decision, :support_status).to_s.upcase == "SUPPORTED"
98
+ return {
99
+ decision_id: decision_id, effective_masks_by_vector: {}, condition_results: [], witness_buckets: {},
100
+ conditions: [], unsupported: true,
101
+ completeness: { observation: true, attribution: true, analysis: true },
102
+ diagnostics: [diagnostic("unsupported_decision", "warning", decision_id, nil)]
103
+ }
104
+ end
105
+
106
+ vectors = @vectors.filter_map { |vector| valid_vector(vector, decision) }
107
+ masks = vectors.to_h { |vector| [id(vector, :id), effective_mask(vector, decision_id)] }
108
+ buckets = {}
109
+ conditions(decision).each do |condition|
110
+ index = id(condition, :index)
111
+ # These keys are serialized enum labels, not Boolean values.
112
+ # rubocop:disable-next Lint/BooleanSymbol -- serialized enum labels
113
+ buckets[index] = { true: [], false: [] }
114
+ vectors.each do |vector|
115
+ mask = masks[id(vector, :id)]
116
+ next unless mask&.anybits?(1 << index)
117
+
118
+ # rubocop:disable-next Lint/BooleanSymbol -- serialized enum labels
119
+ buckets[index][value(vector, index) ? :true : :false] << id(vector, :id)
120
+ end
121
+ buckets[index].each_value(&:sort!)
122
+ end
123
+ results = conditions(decision).map do |condition|
124
+ index = id(condition, :index)
125
+ # rubocop:disable Lint/BooleanSymbol -- serialized enum labels
126
+ true_ids = buckets[index][:true]
127
+ false_ids = buckets[index][:false]
128
+ # rubocop:enable Lint/BooleanSymbol
129
+ pair = first_pair(vectors, index, decision_id)
130
+ {
131
+ condition_id: id(condition, :id),
132
+ status: pair ? "PROVEN" : "NOT_PROVEN",
133
+ canonical_pair: pair && [id(pair[0], :id), id(pair[1], :id)],
134
+ constraint_result: pair ? nil : missing(decision_id: decision_id, condition_index: index),
135
+ reason: if pair
136
+ nil
137
+ else
138
+ (true_ids.empty? || false_ids.empty? ? "missing_effective_sign" : "no_independent_pair")
139
+ end
140
+ }
141
+ end
142
+ {
143
+ decision_id: decision_id,
144
+ effective_masks_by_vector: masks,
145
+ condition_results: results,
146
+ witness_buckets: buckets,
147
+ edge_table: edge_table(decision[:tree]),
148
+ conditions: conditions(decision),
149
+ completeness: { observation: true, attribution: true, analysis: !@analysis_invalid },
150
+ diagnostics: []
151
+ }
152
+ end
153
+
154
+ def valid_vector(vector, decision)
155
+ return nil unless id(vector, :decision_id) == id(decision, :id)
156
+
157
+ decision_source = id(decision, :source_id)
158
+ vector_source = id(vector, :source_id)
159
+ if decision_source && vector_source && decision_source != vector_source
160
+ @analysis_invalid = true
161
+ add_diagnostic("incompatible_evidence", "error", id(vector, :id), id(decision, :id))
162
+ add_diagnostic("invalid_vector", "error", id(vector, :id), id(decision, :id))
163
+ return nil
164
+ end
165
+
166
+ status = id(vector, :status)
167
+ if status && status.to_s != "completed"
168
+ @analysis_invalid = true
169
+ add_diagnostic("incomplete_vector", "error", id(vector, :id))
170
+ return nil
171
+ end
172
+ unless evidence_compatible?(vector)
173
+ @analysis_invalid = true
174
+ add_diagnostic("incompatible_evidence", "error", id(vector, :id))
175
+ return nil
176
+ end
177
+ unless outcome(vector).is_a?(true.class) || outcome(vector).is_a?(false.class)
178
+ @analysis_invalid = true
179
+ add_diagnostic("invalid_vector", "error", id(vector, :id))
180
+ return nil
181
+ end
182
+
183
+ effective_mask(vector, id(decision, :id), decision[:tree])
184
+ vector
185
+ rescue ArgumentError
186
+ @analysis_invalid = true
187
+ add_diagnostic("invalid_vector", "error", id(vector, :id))
188
+ nil
189
+ end
190
+
191
+ def effective_mask(vector, decision_id, tree = nil)
192
+ decision = records(@inventory, :decisions).find { id(_1, :id) == decision_id } unless tree
193
+ tree ||= decision && decision[:tree]
194
+ raise ArgumentError, "unknown decision" unless tree
195
+
196
+ values = values_for(vector)
197
+ index = 0
198
+ result, mask, consumed = replay(tree, values, index)
199
+ unless consumed == values.compact.length && result == outcome(vector)
200
+ raise ArgumentError,
201
+ "invalid structural trace"
202
+ end
203
+
204
+ mask
205
+ end
206
+
207
+ def replay(node, values, offset)
208
+ type = id(node, :type).to_sym
209
+ if type == :atom
210
+ index = id(node, :index)
211
+ value = values[index]
212
+ raise ArgumentError, "missing atom" if value.nil?
213
+
214
+ return [value, 1 << index, offset + 1]
215
+ end
216
+ left_result, left_mask, consumed = replay(node.fetch(:left), values, offset)
217
+ return [left_result, left_mask, consumed] if (type == :and && !left_result) || (type == :or && left_result)
218
+
219
+ right_result, right_mask, consumed = replay(node.fetch(:right), values, consumed)
220
+ if (type == :and && !right_result) || (type == :or && right_result)
221
+ [right_result, right_mask, consumed]
222
+ else
223
+ [right_result, left_mask | right_mask, consumed]
224
+ end
225
+ end
226
+
227
+ def first_pair(vectors, index, decision_id)
228
+ observed_vectors = vectors.select do |vector|
229
+ observed?(vector, index) && effective_mask(vector, decision_id).anybits?(1 << index)
230
+ end
231
+ observed_vectors.sort_by! { |vector| id(vector, :id).to_s }
232
+ grouped = observed_vectors.group_by do |vector|
233
+ # rubocop:disable-next Lint/BooleanSymbol -- serialized enum labels
234
+ value(vector, index) ? :true : :false
235
+ end
236
+ # rubocop:disable Lint/BooleanSymbol -- serialized enum labels
237
+ false_by_outcome = grouped.fetch(:false, []).group_by { |vector| outcome(vector) }
238
+ true_by_outcome = grouped.fetch(:true, []).group_by { |vector| outcome(vector) }
239
+ # rubocop:enable Lint/BooleanSymbol
240
+ false_by_outcome.each do |left_outcome, left_vectors|
241
+ right_vectors = true_by_outcome[!left_outcome]
242
+ next if right_vectors.nil?
243
+
244
+ left = left_vectors.first
245
+ right = right_vectors.first
246
+ return [left, right] if pair?(decision_id: decision_id, condition_index: index, left: left, right: right)
247
+ end
248
+ nil
249
+ end
250
+
251
+ def compatible_vectors?(left, right, decision_id)
252
+ id(left, :decision_id) == decision_id && id(right, :decision_id) == decision_id &&
253
+ source_identity(left) == source_identity(right) && complete?(left) && complete?(right)
254
+ end
255
+
256
+ def counterpart(decision, target, target_value)
257
+ decision_id = id(decision, :id)
258
+ @constraint_states[decision_id] += 1
259
+ return nil if @constraint_states[decision_id] > constraint_limit
260
+
261
+ target_condition = conditions(decision).find { |condition| id(condition, :index) == target }
262
+ return nil if target_condition && !id(target_condition,
263
+ :literal_truth).nil? && id(target_condition, :literal_truth) != target_value
264
+
265
+ return nil unless contains?(decision[:tree], target)
266
+
267
+ constraints = [{ condition_index: target, value: target_value }]
268
+ @constraint_decision_conditions = conditions(decision)
269
+ @constraint_current_decision = decision_id
270
+ return nil unless impose_path(decision[:tree], target, target_value, constraints)
271
+
272
+ values = Array.new(conditions(decision).length, false)
273
+ constraints.each { |constraint| values[constraint[:condition_index]] = constraint[:value] }
274
+ values[target] = target_value
275
+ outcome, observed = evaluate_with_trace(decision[:tree], values)
276
+ sparse = Array.new(values.length)
277
+ observed.each { |index, value| sparse[index] = value }
278
+ vector = { decision_id: id(decision, :id), source_id: id(decision, :source_id), status: "completed",
279
+ values: sparse, outcome: outcome, id: "candidate-#{target}-#{target_value}" }
280
+ constraints.each do |constraint|
281
+ condition = conditions(decision).find { |item| id(item, :index) == constraint[:condition_index] }
282
+ constraint[:condition_id] = id(condition, :id)
283
+ constraint[:required] = constraint[:value]
284
+ end
285
+ { constraints: constraints, vector: vector }
286
+ end
287
+
288
+ def feasibility_statement(candidates)
289
+ return "Boolean requirement; application-level feasibility unknown" unless candidates.empty?
290
+
291
+ "Boolean requirement is not satisfiable in the model; application-level feasibility unknown"
292
+ end
293
+
294
+ def constraint_limit
295
+ value = id(@limits, :constraint_search_states)
296
+ value&.to_i&.positive? ? value.to_i : 10_000
297
+ end
298
+
299
+ def edge_table(node)
300
+ graph = build_graph(node)
301
+ graph[:edges]
302
+ end
303
+
304
+ # rubocop:disable Lint/BooleanSymbol -- serialized enum labels
305
+ def build_graph(node, true_destination = :true, false_destination = :false)
306
+ # rubocop:enable Lint/BooleanSymbol
307
+ if id(node, :type).to_sym == :atom
308
+ index = id(node, :index)
309
+ entry = "atom#{index}"
310
+ edges = [{ from: entry, truth: true, destination: true_destination, clear_mask: 0, index: index },
311
+ { from: entry, truth: false, destination: false_destination, clear_mask: 0, index: index }]
312
+ return { entry: entry, true_exits: [edges[0]], false_exits: [edges[1]], all_bits: 1 << index, edges: edges }
313
+ end
314
+ type = id(node, :type).to_sym
315
+ right = build_graph(node[:right], true_destination, false_destination)
316
+ if type == :and
317
+ left = build_graph(node[:left], right[:entry], false_destination)
318
+ right[:false_exits].each { |edge| edge[:clear_mask] |= left[:all_bits] }
319
+ { entry: left[:entry], true_exits: right[:true_exits], false_exits: left[:false_exits] + right[:false_exits],
320
+ all_bits: left[:all_bits] | right[:all_bits], edges: left[:edges] + right[:edges] }
321
+ else
322
+ left = build_graph(node[:left], true_destination, right[:entry])
323
+ right[:true_exits].each { |edge| edge[:clear_mask] |= left[:all_bits] }
324
+ { entry: left[:entry], true_exits: left[:true_exits] + right[:true_exits], false_exits: right[:false_exits],
325
+ all_bits: left[:all_bits] | right[:all_bits], edges: left[:edges] + right[:edges] }
326
+ end
327
+ end
328
+
329
+ def impose_path(node, target, target_value, constraints)
330
+ return true if id(node, :type).to_sym == :atom && id(node, :index) == target
331
+
332
+ left = node[:left]
333
+ right = node[:right]
334
+ if contains?(left, target)
335
+ neutral = id(node, :type).to_sym == :and
336
+ impose_subtree(right, neutral, constraints) && impose_path(left, target, target_value, constraints)
337
+ elsif contains?(right, target)
338
+ impose_subtree(left, id(node, :type).to_sym == :and, constraints) &&
339
+ impose_path(right, target, target_value, constraints)
340
+ else
341
+ false
342
+ end
343
+ end
344
+
345
+ def impose_subtree(node, desired, constraints)
346
+ @constraint_states[@constraint_current_decision] += 1
347
+ return false if @constraint_states[@constraint_current_decision] > constraint_limit
348
+
349
+ if id(node, :type).to_sym == :atom
350
+ condition = @constraint_decision_conditions.find { |item| id(item, :index) == id(node, :index) }
351
+ return false if condition && !id(condition, :literal_truth).nil? && id(condition, :literal_truth) != desired
352
+
353
+ constraints << { condition_index: id(node, :index), value: desired }
354
+ true
355
+ elsif desired == (id(node, :type).to_sym == :and)
356
+ impose_subtree(node[:left], desired, constraints) && impose_subtree(node[:right], desired, constraints)
357
+ else
358
+ impose_subtree(node[:left], desired, constraints) || impose_subtree(node[:right], desired, constraints)
359
+ end
360
+ end
361
+
362
+ def evaluate(node, values)
363
+ return values[id(node, :index)] if id(node, :type).to_sym == :atom
364
+
365
+ left = evaluate(node[:left], values)
366
+ return left if id(node, :type).to_sym == :and && !left
367
+ return left if id(node, :type).to_sym == :or && left
368
+
369
+ evaluate(node[:right], values)
370
+ end
371
+
372
+ def evaluate_with_trace(node, values, trace = [])
373
+ if id(node, :type).to_sym == :atom
374
+ value = !values[id(node, :index)].nil? && values[id(node, :index)] != false
375
+ trace << [id(node, :index), value]
376
+ return [value, trace]
377
+ end
378
+ left, = evaluate_with_trace(node[:left], values, trace)
379
+ return [left, trace] if id(node, :type).to_sym == :and && !left
380
+ return [left, trace] if id(node, :type).to_sym == :or && left
381
+
382
+ evaluate_with_trace(node[:right], values, trace)
383
+ end
384
+
385
+ def contains?(node, target)
386
+ if id(node,
387
+ :type).to_sym == :atom
388
+ id(node,
389
+ :index) == target
390
+ else
391
+ contains?(node[:left],
392
+ target) || contains?(node[:right], target)
393
+ end
394
+ end
395
+
396
+ def values_for(vector)
397
+ raw = vector[:values] || vector["values"]
398
+ raise ArgumentError, "observation must contain booleans or nil" unless raw.is_a?(Array)
399
+ unless raw.all? { |value| value.nil? || value == true || value == false }
400
+ raise ArgumentError, "observation must contain booleans or nil"
401
+ end
402
+
403
+ raw
404
+ end
405
+
406
+ def outcome(vector) = vector[:outcome].nil? ? vector["outcome"] : vector[:outcome]
407
+ def value(vector, index) = values_for(vector)[index]
408
+ def observed?(vector, index) = !value(vector, index).nil?
409
+
410
+ def complete?(vector)
411
+ status = vector[:status] || vector["status"]
412
+ status.nil? || status.to_s == "completed"
413
+ end
414
+
415
+ def source_identity(vector) = vector[:source_id] || vector["source_id"]
416
+
417
+ def id(record, key) = record[key].nil? ? record[key.to_s] : record[key]
418
+
419
+ def records(record, key) = Array(id(record, key))
420
+ def conditions(decision) = records(decision, :conditions)
421
+
422
+ def evidence_compatible?(_vector)
423
+ schema = id(@evidence, :schema_version)
424
+ criterion = id(@evidence, :criterion_version)
425
+ (schema.nil? || schema.to_s.split(".").first == "1") &&
426
+ (criterion.nil? || criterion.to_s == CRITERION)
427
+ end
428
+
429
+ def diagnostic(code, severity, decision_id, vector_id)
430
+ { code: code, severity: severity, message: code.to_s, source_id: nil,
431
+ decision_id: decision_id, execution_id: nil, test_id: nil,
432
+ details: { vector_id: vector_id } }
433
+ end
434
+
435
+ def add_diagnostic(code, severity, vector_id, decision_id = nil)
436
+ key = [code, vector_id, decision_id]
437
+ return if @diagnostic_keys.include?(key)
438
+
439
+ @diagnostic_keys << key
440
+ @invalid_diagnostics << diagnostic(code, severity, decision_id, vector_id)
441
+ end
442
+
443
+ def completeness(decisions)
444
+ evidence_completeness = id(@evidence, :completeness) || {}
445
+ nested_analysis = evidence_completeness.fetch(:analysis, evidence_completeness.fetch("analysis", true))
446
+ { observation: evidence_completeness.fetch(:observation, evidence_completeness.fetch("observation", true)),
447
+ attribution: evidence_completeness.fetch(:attribution, evidence_completeness.fetch("attribution", true)),
448
+ analysis: !@analysis_invalid && nested_analysis && decisions.all? do |decision|
449
+ decision[:completeness][:analysis]
450
+ end &&
451
+ id(@evidence, :analysis) != false }
452
+ end
453
+ end
454
+ end