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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +36 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +202 -0
- data/NOTICE +3 -0
- data/README.md +242 -0
- data/doc/Branchproof/Analyzer.md +26 -0
- data/doc/Branchproof/CLI.md +15 -0
- data/doc/Branchproof/Error.md +6 -0
- data/doc/Branchproof/Evidence.md +46 -0
- data/doc/Branchproof/Instrumenter.md +18 -0
- data/doc/Branchproof/Limits.md +21 -0
- data/doc/Branchproof/Loader.md +25 -0
- data/doc/Branchproof/Minimizer.md +15 -0
- data/doc/Branchproof/MinitestAdapter.md +28 -0
- data/doc/Branchproof/Project.md +23 -0
- data/doc/Branchproof/RailsSupport/Error.md +6 -0
- data/doc/Branchproof/RailsSupport.md +32 -0
- data/doc/Branchproof/Records.md +38 -0
- data/doc/Branchproof/Report.md +26 -0
- data/doc/Branchproof/Runtime.md +37 -0
- data/doc/Branchproof/Source.md +23 -0
- data/doc/Branchproof/Worker.md +45 -0
- data/doc/Branchproof.md +33 -0
- data/doc/CHANGELOG.md +36 -0
- data/doc/README.md +242 -0
- data/exe/mcdc +6 -0
- data/lib/branchproof/analyzer.rb +454 -0
- data/lib/branchproof/cli.rb +266 -0
- data/lib/branchproof/evidence.rb +484 -0
- data/lib/branchproof/instrumenter.rb +150 -0
- data/lib/branchproof/limits.rb +44 -0
- data/lib/branchproof/loader.rb +140 -0
- data/lib/branchproof/minimizer.rb +198 -0
- data/lib/branchproof/minitest_adapter.rb +245 -0
- data/lib/branchproof/project.rb +53 -0
- data/lib/branchproof/rails_support.rb +74 -0
- data/lib/branchproof/records.rb +76 -0
- data/lib/branchproof/report.rb +412 -0
- data/lib/branchproof/runtime.rb +171 -0
- data/lib/branchproof/source.rb +238 -0
- data/lib/branchproof/version.rb +5 -0
- data/lib/branchproof/worker.rb +145 -0
- data/lib/branchproof.rb +24 -0
- data/lib/mcdc.rb +3 -0
- data/llms.txt +33 -0
- data/sig/branchproof.rbs +116 -0
- 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
|