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,484 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable Layout/LineLength
4
+ require "digest"
5
+ require "json"
6
+ require_relative "version" unless defined?(Branchproof::VERSION)
7
+ require_relative "records" unless defined?(Branchproof::Records)
8
+ require_relative "limits" unless defined?(Branchproof::Limits)
9
+ module Branchproof
10
+ # Validates, groups, and merges adapter-neutral execution evidence.
11
+ # Stores validated observations and merges compatible worker snapshots.
12
+ class Evidence
13
+ SCHEMA_VERSION = "1.0"
14
+ CRITERION_VERSION = "masking_occurrence_v1"
15
+ TOOL_VERSION = Branchproof::VERSION
16
+ attr_reader :inventory, :run_id
17
+
18
+ def initialize(inventory:, limits:, run_id:)
19
+ raise ArgumentError, "inventory is required" if inventory.nil?
20
+ raise ArgumentError, "run_id is required" if run_id.nil? || run_id.to_s.empty?
21
+
22
+ @inventory = inventory
23
+ @limits = normalize_limits(limits)
24
+ @run_id = run_id.to_s
25
+ @run_ids = [@run_id]
26
+ @vectors = {}
27
+ @tests = {}
28
+ @run_payloads = {}
29
+ @abort_counts = Hash.new(0)
30
+ @diagnostics = []
31
+ @limited = false
32
+ @attribution_complete = true
33
+ end
34
+
35
+ def register_test(test:)
36
+ value = symbolize(test)
37
+ id = (value[:id] || test_id(value)).to_s
38
+ if !@tests.key?(id) && @tests.length >= @limits[:tests_per_run]
39
+ @attribution_complete = false
40
+ @limited = true
41
+ return status("limited", "tests_per_run reached")
42
+ end
43
+ current = @tests[id] || { id: id, adapter: "unknown", name: id, source: nil, class_name: nil,
44
+ method_name: nil, status: "unknown", phase_counts: {} }
45
+ merged = current.merge(value).merge(id: id)
46
+ merged[:phase_counts] = (current[:phase_counts] || {}).merge(value[:phase_counts] || {})
47
+ @tests[id] = deep_dup(merged)
48
+ status("registered", nil)
49
+ end
50
+
51
+ def record(execution:)
52
+ value = symbolize(execution)
53
+ reason = validate_execution(value)
54
+ return reject_record(reason) if reason
55
+
56
+ if value[:status].to_s == "invalid"
57
+ diagnose(diagnostic: { code: "invalid_execution", severity: "error", message: "invalid execution trace",
58
+ decision_id: value[:decision_id] })
59
+ return reject_record("invalid execution")
60
+ end
61
+ if value[:status].to_s == "completed" && ![true, false].include?(value[:outcome])
62
+ return reject_record("completed outcome must be boolean")
63
+ end
64
+
65
+ if value[:status].to_s != "completed"
66
+ @abort_counts[value[:decision_id].to_s] += 1
67
+ return status("recorded", nil)
68
+ end
69
+ decision_id = value[:decision_id].to_s
70
+ vector_values = condition_values(decision_id, value[:observations])
71
+ if new_vector?(decision_id, vector_values,
72
+ value[:outcome]) && vector_count(decision_id) >= @limits[:vectors_per_decision]
73
+ @limited = true
74
+ return status("limited", "vectors_per_decision reached")
75
+ end
76
+ vector = vector_for(decision_id, vector_values, value[:outcome] ? true : false)
77
+ new_owner = if value[:test_id]
78
+ !vector[:test_ids].include?(value[:test_id].to_s)
79
+ else
80
+ vector[:unattributed_count].zero?
81
+ end
82
+ if new_owner && owner_associations >= @limits[:owner_associations_per_run]
83
+ @attribution_complete = false
84
+ @limited = true
85
+ return status("limited", "owner_associations_per_run reached")
86
+ end
87
+ vector[:test_ids] << value[:test_id].to_s if value[:test_id] && !vector[:test_ids].include?(value[:test_id].to_s)
88
+ phase_map = (vector[:phases_by_test][value[:test_id].to_s] ||= []) if value[:test_id]
89
+ phase_map << value[:phase].to_s if phase_map && !phase_map.include?(value[:phase].to_s)
90
+ vector[:unattributed_count] += 1 unless value[:test_id]
91
+ vector[:count] += 1
92
+ if value[:test_id] && @tests.key?(value[:test_id].to_s)
93
+ test = @tests[value[:test_id].to_s]
94
+ phase = value[:phase].to_s
95
+ test[:phase_counts][phase] = test[:phase_counts].fetch(phase, 0) + 1
96
+ end
97
+ status("recorded", nil)
98
+ rescue StandardError => e
99
+ diagnose(diagnostic: { code: "record_failure", severity: "error", message: e.message })
100
+ status("rejected", e.message)
101
+ end
102
+
103
+ def diagnose(diagnostic:)
104
+ @diagnostics << deep_freeze(symbolize(diagnostic))
105
+ nil
106
+ end
107
+
108
+ def snapshot
109
+ deep_freeze(deep_dup({ schema_version: SCHEMA_VERSION, tool_version: TOOL_VERSION,
110
+ criterion_version: CRITERION_VERSION,
111
+ runtime: RUBY_DESCRIPTION, run_ids: @run_ids.dup, inventory_digest: inventory_digest,
112
+ source_digests: source_digests, condition_shapes: condition_shapes,
113
+ tests: @tests.values, vectors: @vectors.values,
114
+ abort_counts: @abort_counts.dup, diagnostics: @diagnostics.map(&:dup),
115
+ completeness: { observation: !@limited, attribution: @attribution_complete,
116
+ analysis: true } }))
117
+ end
118
+
119
+ def merge(snapshot:)
120
+ backup = nil
121
+ incoming = symbolize(snapshot)
122
+ shape_error = validate_snapshot_shape(incoming)
123
+ return status("rejected", shape_error) if shape_error
124
+
125
+ reason = merge_error(incoming)
126
+ return status("rejected", reason) if reason
127
+
128
+ incoming_runs = Array(incoming[:run_ids]).map(&:to_s)
129
+ return status("rejected", "run_ids must be non-empty") if incoming_runs.empty? || incoming_runs.any?(&:empty?)
130
+
131
+ reason = validate_snapshot_vectors(incoming)
132
+ return status("rejected", reason) if reason
133
+
134
+ payload = canonical(incoming)
135
+ existing = incoming_runs.select { |id| @run_payloads.key?(id) }
136
+ if existing.any? && existing.any? { |id| @run_payloads[id] != Branchproof::Records.id(payload) }
137
+ return status("rejected", "ambiguous overlapping run IDs")
138
+ end
139
+ return status("merged", nil) if existing.length == incoming_runs.length
140
+ return status("rejected", "ambiguous overlapping run IDs") if existing.any?
141
+
142
+ if incoming_owner_associations(incoming) > @limits[:owner_associations_per_run]
143
+ @limited = true
144
+ @attribution_complete = false
145
+ return status("limited", "owner_associations_per_run reached")
146
+ end
147
+ backup = capture_state
148
+ incoming_runs.each do |id|
149
+ next if @run_payloads.key?(id)
150
+
151
+ # All validation is complete before this first mutation, so a malformed
152
+ # vector cannot leave a half-merged worker snapshot behind.
153
+ @run_payloads[id] = Branchproof::Records.id(payload)
154
+ end
155
+ @run_ids |= incoming_runs
156
+ incoming.fetch(:tests, []).each { register_test(test: _1) }
157
+ incoming.fetch(:vectors, []).each { merge_vector(_1) }
158
+ @diagnostics.concat(incoming.fetch(:diagnostics, []))
159
+ @abort_counts.merge!(incoming.fetch(:abort_counts, {})) { |_k, a, b| a.to_i + b.to_i }
160
+ @limited ||= !incoming.dig(:completeness, :observation)
161
+ @attribution_complete &&= incoming.dig(:completeness, :attribution) ? true : false
162
+ status("merged", nil)
163
+ rescue StandardError => e
164
+ restore_state(backup) if backup
165
+ status("rejected", e.message)
166
+ end
167
+
168
+ private
169
+
170
+ def validate_snapshot_shape(incoming)
171
+ return "snapshot must be a hash" unless incoming.is_a?(Hash)
172
+
173
+ required = %i[schema_version tool_version criterion_version runtime run_ids inventory_digest source_digests
174
+ condition_shapes tests vectors abort_counts diagnostics completeness]
175
+ missing = required.reject { |key| incoming.key?(key) }
176
+ return "missing snapshot field: #{missing.first}" unless missing.empty?
177
+
178
+ valid_run_ids = incoming[:run_ids].is_a?(Array) && incoming[:run_ids].all?(String)
179
+ return "invalid run_ids" unless valid_run_ids
180
+ return "invalid source_digests" unless incoming[:source_digests].is_a?(Hash)
181
+ return "invalid condition_shapes" unless incoming[:condition_shapes].is_a?(Hash)
182
+ return "invalid diagnostics" unless incoming[:diagnostics].is_a?(Array)
183
+
184
+ completeness = incoming[:completeness]
185
+ valid_completeness = completeness.is_a?(Hash)
186
+ valid_completeness &&= %i[observation attribution analysis].all? do |key|
187
+ [true, false].include?(completeness[key])
188
+ end
189
+ return "invalid completeness" unless valid_completeness
190
+
191
+ nil
192
+ end
193
+
194
+ def validate_execution(value)
195
+ return "execution must be a hash" unless value.is_a?(Hash)
196
+
197
+ %i[run_id execution_id decision_id test_id phase owner observations outcome status].each do |key|
198
+ return "missing #{key}" unless value.key?(key)
199
+ end
200
+ return "run mismatch" unless value[:run_id].to_s == @run_id
201
+ return "invalid owner" unless value[:owner].is_a?(Hash)
202
+ return "invalid status" unless %w[completed aborted invalid].include?(value[:status].to_s)
203
+ return "invalid phase" unless %w[setup body teardown suite unattributed].include?(value[:phase].to_s)
204
+ return "invalid observations" unless value[:observations].is_a?(Array) && value[:observations].all? do |pair|
205
+ pair.is_a?(Array) && pair.length == 2 && pair[0].is_a?(Integer) && [true, false].include?(pair[1])
206
+ end
207
+
208
+ decision = decisions.find { |d| d[:id].to_s == value[:decision_id].to_s }
209
+ return "unknown decision" unless decision
210
+
211
+ conditions = Array(decision[:conditions])
212
+ return "condition count exceeds limit" if conditions.length > @limits[:conditions_per_decision]
213
+ return "invalid condition index" unless value[:observations].map(&:first).uniq == value[:observations].map(&:first) && value[:observations].all? do |index, _|
214
+ conditions.any? do |c|
215
+ c[:index].to_i == index
216
+ end
217
+ end
218
+ return "invalid trace" unless value[:status].to_s != "completed" || valid_trace?(decision, value[:observations],
219
+ value[:outcome])
220
+
221
+ nil
222
+ end
223
+
224
+ def valid_trace?(decision, observations, outcome)
225
+ tree = symbolize(decision[:tree])
226
+ unless tree
227
+ return observations.map(&:first) == observations.map(&:first).sort &&
228
+ (outcome ? true : false) == evaluate_fallback(observations)
229
+ end
230
+
231
+ cursor = 0
232
+ result = replay_tree(tree, observations, cursor)
233
+ return false unless result
234
+
235
+ value, consumed = result
236
+ consumed == observations.length && value == (outcome ? true : false)
237
+ end
238
+
239
+ def replay_tree(node, observations, cursor)
240
+ node = symbolize(node)
241
+ type = node[:type].to_s
242
+ if type == "atom"
243
+ pair = observations[cursor]
244
+ return nil unless pair && pair[0].to_i == node[:index].to_i
245
+
246
+ return [pair[1], cursor + 1]
247
+ end
248
+ left = replay_tree(node.fetch(:left), observations, cursor)
249
+ return nil unless left
250
+
251
+ left_value, next_cursor = left
252
+ return [left_value, next_cursor] if (type == "and" && !left_value) || (type == "or" && left_value)
253
+
254
+ right = replay_tree(node.fetch(:right), observations, next_cursor)
255
+ return nil unless right
256
+
257
+ right_value, right_cursor = right
258
+ [type == "and" ? (left_value && right_value) : (left_value || right_value), right_cursor]
259
+ rescue KeyError
260
+ nil
261
+ end
262
+
263
+ def evaluate_fallback(observations)
264
+ observations.last&.last
265
+ end
266
+
267
+ def condition_values(decision_id, observations)
268
+ count = decision_conditions(decision_id).length
269
+ values = Array.new(count)
270
+ observations.each { |index, value| values[index] = value }
271
+ values
272
+ end
273
+
274
+ def vector_for(decision_id, values, outcome)
275
+ id = Branchproof::Records.id([decision_id, values, outcome])
276
+ @vectors[id] ||= { id: id, decision_id: decision_id, values: values.dup, outcome: outcome,
277
+ test_ids: [], phases_by_test: {}, unattributed_count: 0, count: 0 }
278
+ end
279
+
280
+ def merge_vector(raw)
281
+ vector = symbolize(raw)
282
+ existing = @vectors[vector[:id].to_s]
283
+ if existing
284
+ existing[:test_ids] |= Array(vector[:test_ids]).map(&:to_s)
285
+ vector.fetch(:phases_by_test, {}).each do |test, phases|
286
+ existing[:phases_by_test][test.to_s] = (existing[:phases_by_test][test.to_s] || []) | phases
287
+ end
288
+ existing[:count] += vector[:count].to_i
289
+ existing[:unattributed_count] += vector[:unattributed_count].to_i
290
+ else
291
+ @vectors[vector[:id].to_s] = { id: vector[:id].to_s, decision_id: vector[:decision_id].to_s,
292
+ values: Array(vector[:values]).dup, outcome: !!vector[:outcome], test_ids: Array(vector[:test_ids]).map(&:to_s),
293
+ phases_by_test: vector.fetch(:phases_by_test, {}).transform_keys(&:to_s), unattributed_count: vector[:unattributed_count].to_i, count: vector[:count].to_i }
294
+ end
295
+ end
296
+
297
+ def merge_error(incoming)
298
+ return "unsupported schema" unless incoming[:schema_version].to_s == SCHEMA_VERSION
299
+ return "criterion mismatch" unless incoming[:criterion_version].to_s == CRITERION_VERSION
300
+ return "inventory mismatch" unless incoming[:inventory_digest].to_s == inventory_digest
301
+ return "source mismatch" unless normalize_mapping(incoming[:source_digests]) == normalize_mapping(source_digests)
302
+ return "condition shape mismatch" unless canonical(incoming[:condition_shapes]) == canonical(condition_shapes)
303
+
304
+ nil
305
+ end
306
+
307
+ def condition_shapes
308
+ decisions.to_h do |decision|
309
+ [decision[:id].to_s, { conditions: Array(decision[:conditions]).map do |condition|
310
+ symbolize(condition)
311
+ end, tree: symbolize(decision[:tree]) }]
312
+ end
313
+ end
314
+
315
+ def validate_snapshot_vectors(incoming)
316
+ vectors = incoming.fetch(:vectors, [])
317
+ return "vectors must be an array" unless vectors.is_a?(Array)
318
+
319
+ tests = incoming.fetch(:tests, [])
320
+ return "tests must be an array" unless tests.is_a?(Array)
321
+ return "tests_per_run reached" if (@tests.keys | tests.filter_map do |item|
322
+ symbolize(item)[:id]
323
+ end).length > @limits[:tests_per_run]
324
+ return "invalid test record" unless tests.all? do |raw|
325
+ test = symbolize(raw)
326
+ test[:id] && test[:adapter] && test[:name] && test[:phase_counts].is_a?(Hash) &&
327
+ test[:phase_counts].all? do |phase, count|
328
+ %w[setup body teardown suite unattributed].include?(phase.to_s) && count.is_a?(Integer) && count >= 0
329
+ end
330
+ end
331
+
332
+ counts = Hash.new(0)
333
+ seen_ids = {}
334
+ vectors.each do |raw|
335
+ vector = symbolize(raw)
336
+ return "invalid vector" unless vector[:id] && vector[:decision_id] && vector[:values].is_a?(Array)
337
+
338
+ decision = decisions.find { |item| item[:id].to_s == vector[:decision_id].to_s }
339
+ return "unknown decision" unless decision
340
+ return "invalid vector shape" unless vector[:values].length == Array(decision[:conditions]).length &&
341
+ vector[:values].all? { |item| item.nil? || item == true || item == false }
342
+
343
+ expected = Branchproof::Records.id([vector[:decision_id].to_s, vector[:values], vector[:outcome] ? true : false])
344
+ return "invalid vector id" unless vector[:id].to_s == expected
345
+ return "invalid vector outcome" unless [true, false].include?(vector[:outcome])
346
+ return "invalid vector count" unless vector[:count].is_a?(Integer) && vector[:count].positive?
347
+ unless vector[:unattributed_count].is_a?(Integer) && vector[:unattributed_count] >= 0 && vector[:unattributed_count] <= vector[:count]
348
+ return "invalid unattributed count"
349
+ end
350
+ unless vector[:test_ids].is_a?(Array) && vector[:test_ids].uniq.length == vector[:test_ids].length && vector[:phases_by_test].is_a?(Hash)
351
+ return "invalid vector provenance"
352
+ end
353
+ return "invalid vector provenance" unless vector[:phases_by_test].all? do |test, phases|
354
+ test && phases.is_a?(Array) && phases.all? do |phase|
355
+ %w[setup body teardown suite unattributed].include?(phase.to_s)
356
+ end
357
+ end
358
+ return "invalid vector trace" unless valid_trace?(decision, vector[:values].each_with_index.filter_map do |item, index|
359
+ [index, item] if [true, false].include?(item)
360
+ end, vector[:outcome])
361
+ return "duplicate vector id" if seen_ids.key?(vector[:id].to_s)
362
+
363
+ seen_ids[vector[:id].to_s] = true
364
+ counts[vector[:decision_id].to_s] += 1 unless @vectors.key?(vector[:id].to_s)
365
+ end
366
+ return "vectors_per_decision reached" if counts.any? do |id, count|
367
+ vector_count(id) + count > @limits[:vectors_per_decision]
368
+ end
369
+
370
+ abort_counts = incoming.fetch(:abort_counts, {})
371
+ return "invalid abort counts" unless abort_counts.is_a?(Hash) && abort_counts.all? do |_id, count|
372
+ count.is_a?(Integer) && count >= 0
373
+ end
374
+
375
+ nil
376
+ end
377
+
378
+ def decisions = Array(fetch_value(@inventory, :decisions)).map { symbolize(_1) }
379
+ def decision_conditions(id) = (decisions.find { |d| d[:id].to_s == id.to_s } || {}).fetch(:conditions, [])
380
+ def vector_count(id) = @vectors.values.count { |v| v[:decision_id] == id.to_s }
381
+ def new_vector?(id, values, outcome) = !@vectors.key?(Branchproof::Records.id([id, values, outcome]))
382
+
383
+ def owner_associations
384
+ @vectors.values.sum do |v|
385
+ v[:test_ids].length + (v[:unattributed_count].positive? ? 1 : 0)
386
+ end
387
+ end
388
+
389
+ def incoming_owner_associations(incoming)
390
+ owners = @vectors.each_with_object({}) do |(_id, vector), result|
391
+ vector[:test_ids].each { |test_id| result[[vector[:id], test_id]] = true }
392
+ result[[vector[:id], :unattributed]] = true if vector[:unattributed_count].positive?
393
+ end
394
+ incoming.fetch(:vectors, []).each do |raw|
395
+ vector = symbolize(raw)
396
+ vector.fetch(:test_ids, []).each { |test_id| owners[[vector[:id], test_id.to_s]] = true }
397
+ owners[[vector[:id], :unattributed]] = true if vector.fetch(:unattributed_count, 0).to_i.positive?
398
+ end
399
+ owners.length
400
+ end
401
+
402
+ def inventory_digest = Branchproof::Records.id({ sources: source_digests, condition_shapes: condition_shapes })
403
+
404
+ def source_digests
405
+ explicit = fetch_value(@inventory, :source_digests)
406
+ return normalize_mapping(explicit) unless explicit.nil? || explicit.empty?
407
+
408
+ units = Array(fetch_value(@inventory, :source_units))
409
+ units.each_with_object({}) do |unit, result|
410
+ item = symbolize(unit)
411
+ key = item[:relative_path] || item[:source_id] || item[:absolute_path]
412
+ result[key.to_s] = item[:digest].to_s if key && item[:digest]
413
+ end
414
+ end
415
+
416
+ def fetch_value(object, key) = object.respond_to?(key) ? object.public_send(key) : object[key] || object[key.to_s]
417
+ def status(name, reason) = { status: name.to_s, reason: reason, diagnostics: @diagnostics.map(&:dup) }
418
+
419
+ def reject_record(reason)
420
+ @limited = true
421
+ diagnose(diagnostic: { code: "invalid_execution", severity: "error", message: reason.to_s })
422
+ status("rejected", reason.to_s)
423
+ end
424
+
425
+ def test_id(value) = Branchproof::Records.id(value)
426
+
427
+ def normalize_limits(value)
428
+ return Branchproof::Limits.default if value.nil? || value == {}
429
+ return Branchproof::Limits.normalize(value.to_h) if value.respond_to?(:to_h)
430
+
431
+ raise ArgumentError, "limits must be a Hash"
432
+ end
433
+
434
+ def symbolize(value)
435
+ return value.map { symbolize(_1) } if value.is_a?(Array)
436
+ return value.transform_keys(&:to_sym).transform_values { symbolize(_1) } if value.is_a?(Hash)
437
+
438
+ value
439
+ end
440
+
441
+ def canonical(value) = Branchproof::Records.canonical(value)
442
+
443
+ def normalize_mapping(value)
444
+ (value || {}).each_with_object({}) { |(key, item), result| result[key.to_s] = item.to_s }
445
+ end
446
+
447
+ def deep_dup(value)
448
+ case value
449
+ when Hash then value.each_with_object({}) { |(key, item), copy| copy[deep_dup(key)] = deep_dup(item) }
450
+ when Array then value.map { deep_dup(_1) }
451
+ else value
452
+ end
453
+ end
454
+
455
+ def capture_state
456
+ { vectors: deep_dup(@vectors), tests: deep_dup(@tests), run_ids: @run_ids.dup,
457
+ run_payloads: @run_payloads.dup, diagnostics: deep_dup(@diagnostics),
458
+ abort_counts: @abort_counts.dup, limited: @limited, attribution_complete: @attribution_complete }
459
+ end
460
+
461
+ def restore_state(state)
462
+ @vectors = state[:vectors]
463
+ @tests = state[:tests]
464
+ @run_ids = state[:run_ids]
465
+ @run_payloads = state[:run_payloads]
466
+ @diagnostics = state[:diagnostics]
467
+ @abort_counts = state[:abort_counts]
468
+ @limited = state[:limited]
469
+ @attribution_complete = state[:attribution_complete]
470
+ end
471
+
472
+ def deep_freeze(value)
473
+ case value
474
+ when Hash then value.each do |key, item|
475
+ deep_freeze(key)
476
+ deep_freeze(item)
477
+ end
478
+ when Array then value.each { deep_freeze(_1) }
479
+ end
480
+ value.freeze
481
+ end
482
+ end
483
+ end
484
+ # rubocop:enable Layout/LineLength
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Branchproof
4
+ # Applies the smallest possible source edits around inventoried expressions.
5
+ # The edits are deliberately textual: Prism owns the ranges, while this class
6
+ # never evaluates application code or introduces a Ruby scope.
7
+ class Instrumenter
8
+ RUNTIME = "::Branchproof::Runtime"
9
+
10
+ def rewrite(unit:)
11
+ bytes = unit.fetch(:original_bytes).dup.force_encoding(Encoding::BINARY)
12
+ reasons = Array(unit[:support_reasons])
13
+ unless supported_unit?(unit, reasons)
14
+ return result(bytes, diagnostics: [diagnostic("unsupported_source", reasons.join(", "))])
15
+ end
16
+
17
+ decisions = Array(unit[:decisions]).select { |decision| supported?(decision) }
18
+ edits = decisions.filter_map do |decision|
19
+ next if decisions.any? do |outer|
20
+ outer != decision && contains?(outer[:byte_start], outer[:byte_length], decision[:byte_start],
21
+ decision[:byte_length])
22
+ end
23
+
24
+ decision_edit(bytes, decision, decisions)
25
+ end
26
+ if decisions.any? && edits.empty?
27
+ return result(bytes,
28
+ diagnostics: [diagnostic("invalid_range",
29
+ "no valid decision ranges")])
30
+ end
31
+
32
+ rewritten = apply_edits(bytes, edits)
33
+ begin
34
+ RubyVM::InstructionSequence.compile(rewritten, unit[:absolute_path] || "(branchproof)",
35
+ unit[:real_path] || unit[:absolute_path] || "(branchproof)", 1)
36
+ rescue SyntaxError => e
37
+ return result(bytes, diagnostics: [diagnostic("invalid_rewrite", e.message)])
38
+ end
39
+ result(rewritten.force_encoding(unit[:original_bytes].encoding), changed: rewritten != bytes)
40
+ end
41
+
42
+ private
43
+
44
+ def supported_unit?(unit, _reasons)
45
+ status = unit[:support_status]
46
+ status.nil? || status.to_s.casecmp("supported").zero?
47
+ end
48
+
49
+ def supported?(decision)
50
+ status = decision[:support_status]
51
+ status.nil? || status.to_s.casecmp("supported").zero?
52
+ end
53
+
54
+ def decision_edit(bytes, decision, all_decisions)
55
+ start = decision[:byte_start]
56
+ length = decision[:byte_length]
57
+ return nil unless valid_range?(bytes, start, length)
58
+
59
+ nested = all_decisions.select do |candidate|
60
+ candidate != decision && contains?(start, length, candidate[:byte_start], candidate[:byte_length])
61
+ end
62
+ expression = render_range(bytes, start, length, decision, nested)
63
+ {
64
+ start: start,
65
+ finish: start + length,
66
+ text: frame(decision[:id], expression)
67
+ }
68
+ end
69
+
70
+ def render_range(bytes, start, length, decision, nested)
71
+ conditions = Array(decision[:conditions]).sort_by { |condition| condition[:byte_start] }
72
+ cursor = start
73
+ chunks = []
74
+ conditions.each do |condition|
75
+ cstart = condition[:byte_start]
76
+ clen = condition[:byte_length]
77
+ next unless valid_range?(bytes, cstart, clen) && cstart >= start && cstart + clen <= start + length
78
+
79
+ chunks << bytes.byteslice(cursor, cstart - cursor)
80
+ original = render_children(bytes, cstart, clen, nested)
81
+ chunks << condition_wrapper(decision[:id], condition[:index], original)
82
+ cursor = cstart + clen
83
+ end
84
+ chunks << render_children(bytes, cursor, length - (cursor - start), nested)
85
+ chunks.join
86
+ end
87
+
88
+ def render_children(bytes, start, length, nested)
89
+ children = nested.select do |child|
90
+ contains?(start, length, child[:byte_start], child[:byte_length])
91
+ end
92
+ children = children.reject do |child|
93
+ nested.any? do |candidate|
94
+ candidate != child && contains?(candidate[:byte_start], candidate[:byte_length], child[:byte_start],
95
+ child[:byte_length])
96
+ end
97
+ end
98
+ children.sort_by! { |child| -child[:byte_start] }
99
+ output = bytes.byteslice(start, length)
100
+ children.each do |child|
101
+ child_text = render_range(bytes, child[:byte_start], child[:byte_length], child, nested.reject do |item|
102
+ item.equal?(child)
103
+ end)
104
+ child_text = frame(child[:id], child_text)
105
+ offset = child[:byte_start] - start
106
+ output[offset, child[:byte_length]] = child_text
107
+ end
108
+ output
109
+ end
110
+
111
+ def condition_wrapper(decision_id, index, expression)
112
+ "#{RUNTIME}.condition(#{decision_id.inspect}, #{index}, (#{expression}))"
113
+ end
114
+
115
+ def frame(decision_id, expression)
116
+ "(begin; #{RUNTIME}.enter(#{decision_id.inspect}); begin; " \
117
+ "#{RUNTIME}.finish(#{decision_id.inspect}, (#{expression})); ensure; " \
118
+ "#{RUNTIME}.leave(#{decision_id.inspect}); end; end)"
119
+ end
120
+
121
+ def apply_edits(bytes, edits)
122
+ edits.sort_by { |edit| -edit[:start] }.each_with_object(bytes.dup) do |edit, output|
123
+ output[edit[:start]...edit[:finish]] = edit[:text]
124
+ end
125
+ end
126
+
127
+ def valid_range?(bytes, start, length)
128
+ start.is_a?(Integer) && length.is_a?(Integer) && start >= 0 && length >= 0 && start + length <= bytes.bytesize
129
+ end
130
+
131
+ def contains?(outer_start, outer_length, inner_start, inner_length)
132
+ valid_integer_range?(inner_start,
133
+ inner_length) && inner_start >= outer_start &&
134
+ inner_start + inner_length <= outer_start + outer_length
135
+ end
136
+
137
+ def valid_integer_range?(start, length)
138
+ start.is_a?(Integer) && length.is_a?(Integer) && start >= 0 && length >= 0
139
+ end
140
+
141
+ def result(bytes, changed: false, diagnostics: [])
142
+ { bytes: bytes, changed: changed, diagnostics: diagnostics }.freeze
143
+ end
144
+
145
+ def diagnostic(code, message)
146
+ { code: code.to_s, severity: "warning", message: message, source_id: nil, decision_id: nil,
147
+ execution_id: nil, test_id: nil, details: {} }.freeze
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Branchproof
4
+ # Defines bounded storage and search limits for one analysis.
5
+ module Limits
6
+ KEYS = %i[
7
+ conditions_per_decision vectors_per_decision owner_associations_per_run
8
+ tests_per_run exact_candidates exact_search_nodes constraint_search_states
9
+ ].freeze
10
+ DEFAULTS = {
11
+ conditions_per_decision: 64,
12
+ vectors_per_decision: 8192,
13
+ owner_associations_per_run: 200_000,
14
+ tests_per_run: 50_000,
15
+ exact_candidates: 32,
16
+ exact_search_nodes: 100_000,
17
+ constraint_search_states: 10_000
18
+ }.freeze
19
+
20
+ module_function
21
+
22
+ def default
23
+ Records.build(DEFAULTS)
24
+ end
25
+
26
+ def normalize(overrides = {})
27
+ raise ArgumentError, "limits must be a Hash" unless overrides.is_a?(Hash)
28
+
29
+ normalized_overrides = overrides.each_with_object({}) do |(key, value), result|
30
+ raise ArgumentError, "limit keys must be Symbols or Strings" unless key.is_a?(String) || key.is_a?(Symbol)
31
+
32
+ result[key.to_sym] = value
33
+ end
34
+ values = DEFAULTS.merge(normalized_overrides)
35
+ unknown = values.keys - KEYS
36
+ raise ArgumentError, "unknown limit: #{unknown.first}" unless unknown.empty?
37
+
38
+ values.each do |key, value|
39
+ raise ArgumentError, "#{key} must be a positive Integer" unless value.is_a?(Integer) && value.positive?
40
+ end
41
+ Records.build(values)
42
+ end
43
+ end
44
+ end