branchproof 0.6.0 → 0.7.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 +4 -4
- data/CHANGELOG.md +10 -0
- data/README.md +74 -23
- data/doc/Branchproof/CoverageIndex.md +3 -0
- data/doc/Branchproof/DecisionSyntax.md +20 -0
- data/doc/Branchproof/FlowInstrumentation.md +7 -0
- data/doc/Branchproof/Instrumenter.md +1 -0
- data/doc/Branchproof/Runtime.md +19 -0
- data/doc/Branchproof/RuntimeFlow.md +27 -0
- data/doc/Branchproof/SavedReport.md +6 -0
- data/doc/Branchproof/Source.md +1 -0
- data/doc/Branchproof.md +6 -2
- data/doc/CHANGELOG.md +10 -0
- data/doc/README.md +74 -23
- data/lib/branchproof/analyzer.rb +153 -21
- data/lib/branchproof/comparison.rb +1 -1
- data/lib/branchproof/coverage_index.rb +90 -2
- data/lib/branchproof/decision_syntax.rb +294 -0
- data/lib/branchproof/evidence.rb +54 -9
- data/lib/branchproof/flow_instrumentation.rb +107 -0
- data/lib/branchproof/focused_report.rb +85 -18
- data/lib/branchproof/instrumenter.rb +28 -13
- data/lib/branchproof/report.rb +195 -24
- data/lib/branchproof/runtime.rb +3 -0
- data/lib/branchproof/runtime_flow.rb +58 -0
- data/lib/branchproof/saved_report.rb +245 -6
- data/lib/branchproof/source.rb +191 -35
- data/lib/branchproof/version.rb +1 -1
- data/llms.txt +6 -2
- data/sig/branchproof.rbs +7 -0
- metadata +7 -1
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
# rubocop:disable Metrics/BlockLength
|
|
4
|
+
|
|
3
5
|
# rubocop:disable Metrics/ClassLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
4
6
|
|
|
5
7
|
require "json"
|
|
@@ -7,7 +9,9 @@ require "json"
|
|
|
7
9
|
module Branchproof
|
|
8
10
|
# Reads and validates a persisted JSON report without loading the project.
|
|
9
11
|
class SavedReport
|
|
10
|
-
SUPPORTED_SCHEMAS = %w[1.0 1.1].freeze
|
|
12
|
+
SUPPORTED_SCHEMAS = %w[1.0 1.1 1.2].freeze
|
|
13
|
+
DECISION_KINDS = %w[boolean implicit multiway pattern exception].freeze
|
|
14
|
+
NONBOOLEAN_KINDS = (DECISION_KINDS - ["boolean"]).freeze
|
|
11
15
|
CRITERION_VERSION = "masking_occurrence_v1"
|
|
12
16
|
REQUIRED_FIELDS = %w[schema_version tool_version criterion_version runtime run_ids source_inventory baseline
|
|
13
17
|
observations analysis minima metrics diagnostics completeness].freeze
|
|
@@ -33,7 +37,8 @@ module Branchproof
|
|
|
33
37
|
|
|
34
38
|
def validate!
|
|
35
39
|
fail_with("document must be an object") unless hash_with_string_keys?(@document)
|
|
36
|
-
|
|
40
|
+
@schema_version = @document["schema_version"]
|
|
41
|
+
schema = @schema_version
|
|
37
42
|
fail_with("unsupported schema version") unless SUPPORTED_SCHEMAS.include?(schema)
|
|
38
43
|
missing = REQUIRED_FIELDS.reject { |field| @document.key?(field) }
|
|
39
44
|
fail_with("missing field: #{missing.first}") unless missing.empty?
|
|
@@ -66,6 +71,9 @@ module Branchproof
|
|
|
66
71
|
decisions.each do |decision|
|
|
67
72
|
fail_with("invalid decision") unless hash_with_string_keys?(decision)
|
|
68
73
|
fail_with("unknown decision source") unless source_ids.include?(decision["source_id"])
|
|
74
|
+
kind = decision_kind(decision)
|
|
75
|
+
fail_with("invalid decision kind") unless DECISION_KINDS.include?(kind)
|
|
76
|
+
validate_string_field(decision, "context", nullable: true)
|
|
69
77
|
validate_string_field(decision, "expression")
|
|
70
78
|
validate_integer_field(decision, "line")
|
|
71
79
|
conditions = decision["conditions"]
|
|
@@ -81,9 +89,17 @@ module Branchproof
|
|
|
81
89
|
fail_with("condition indexes must be consecutive") unless conditions.map do |condition|
|
|
82
90
|
condition["index"]
|
|
83
91
|
end.sort == (0...conditions.length).to_a
|
|
92
|
+
alternatives = decision["alternatives"]
|
|
93
|
+
if nonboolean_kind?(kind)
|
|
94
|
+
fail_with("nonboolean decision tree must be null") unless decision.key?("tree") && decision["tree"].nil?
|
|
95
|
+
validate_alternatives(alternatives)
|
|
96
|
+
elsif alternatives && (!alternatives.is_a?(Array) || !alternatives.empty?)
|
|
97
|
+
fail_with("boolean decision alternatives must be empty")
|
|
98
|
+
end
|
|
84
99
|
end
|
|
85
100
|
@source_ids = source_ids
|
|
86
101
|
@decision_ids = decision_ids
|
|
102
|
+
@decisions_by_id = decisions.to_h { |decision| [decision["id"], decision] }
|
|
87
103
|
@condition_ids = decisions.flat_map do |decision|
|
|
88
104
|
Array(decision["conditions"]).map do |condition|
|
|
89
105
|
condition["id"]
|
|
@@ -102,6 +118,18 @@ module Branchproof
|
|
|
102
118
|
paths = sources.map { |source| source["relative_path"] }.compact
|
|
103
119
|
fail_with("duplicate source relative path") unless paths.uniq.length == paths.length
|
|
104
120
|
@condition_counts = decisions.to_h { |decision| [decision["id"], decision["conditions"].length] }
|
|
121
|
+
@alternative_counts = decisions.to_h do |decision|
|
|
122
|
+
[decision["id"], nonboolean_kind?(decision_kind(decision)) ? decision.fetch("alternatives").length : 0]
|
|
123
|
+
end
|
|
124
|
+
@alternative_ids_by_decision = decisions.to_h do |decision|
|
|
125
|
+
[decision["id"], if nonboolean_kind?(decision_kind(decision))
|
|
126
|
+
decision.fetch("alternatives").map do |alternative|
|
|
127
|
+
alternative["id"]
|
|
128
|
+
end
|
|
129
|
+
else
|
|
130
|
+
[]
|
|
131
|
+
end]
|
|
132
|
+
end
|
|
105
133
|
@supported_ids = decisions.filter_map do |decision|
|
|
106
134
|
decision["id"] unless decision["support_status"] == "UNSUPPORTED"
|
|
107
135
|
end
|
|
@@ -119,14 +147,20 @@ module Branchproof
|
|
|
119
147
|
vectors.each do |vector|
|
|
120
148
|
fail_with("invalid vector") unless hash_with_string_keys?(vector)
|
|
121
149
|
fail_with("unknown vector decision") unless @decision_ids.include?(vector["decision_id"])
|
|
150
|
+
decision = @decisions_by_id.fetch(vector["decision_id"])
|
|
122
151
|
values = vector["values"]
|
|
123
152
|
valid_values = values.is_a?(Array) && values.all? do |value|
|
|
124
153
|
value.nil? || value == true || value == false
|
|
125
154
|
end
|
|
126
155
|
fail_with("vector values must be an array of booleans or null") unless valid_values
|
|
127
|
-
expected_length = @
|
|
128
|
-
|
|
156
|
+
expected_length = if @alternative_counts.fetch(vector["decision_id"]).positive?
|
|
157
|
+
@alternative_counts.fetch(vector["decision_id"])
|
|
158
|
+
else
|
|
159
|
+
@condition_counts.fetch(vector["decision_id"])
|
|
160
|
+
end
|
|
161
|
+
fail_with("vector values do not match decision dimensions") unless values.length == expected_length
|
|
129
162
|
fail_with("vector outcome must be boolean") unless [true, false].include?(vector["outcome"])
|
|
163
|
+
validate_flow_vector(vector, decision) if strict_flow_decision?(decision)
|
|
130
164
|
fail_with("unknown vector test") unless strings?(vector["test_ids"]) && vector["test_ids"].all? do |id|
|
|
131
165
|
@test_ids.include?(id)
|
|
132
166
|
end
|
|
@@ -136,6 +170,7 @@ module Branchproof
|
|
|
136
170
|
end
|
|
137
171
|
@vector_ids = vector_ids
|
|
138
172
|
@vector_decision_by_id = vectors.to_h { |vector| [vector["id"], vector["decision_id"]] }
|
|
173
|
+
@vectors_by_id = vectors.to_h { |vector| [vector["id"], vector] }
|
|
139
174
|
aborts = observations["abort_counts"]
|
|
140
175
|
return if aborts.nil? || aborts.is_a?(Integer) || (aborts.is_a?(Hash) && aborts.values.all?(Integer))
|
|
141
176
|
|
|
@@ -168,6 +203,11 @@ module Branchproof
|
|
|
168
203
|
seen[id] = true
|
|
169
204
|
results = decision["condition_results"]
|
|
170
205
|
fail_with("condition results must be an array") unless results.is_a?(Array)
|
|
206
|
+
inventory_decision = @decisions_by_id[id]
|
|
207
|
+
if nonboolean_kind?(decision_kind(inventory_decision))
|
|
208
|
+
fail_with("nonboolean condition results must be empty") unless results.empty?
|
|
209
|
+
validate_nonboolean_analysis(decision, inventory_decision)
|
|
210
|
+
end
|
|
171
211
|
seen_conditions = {}
|
|
172
212
|
results.each do |result|
|
|
173
213
|
condition_id = result["condition_id"] if hash_with_string_keys?(result)
|
|
@@ -188,6 +228,195 @@ module Branchproof
|
|
|
188
228
|
end
|
|
189
229
|
end
|
|
190
230
|
|
|
231
|
+
def decision_kind(decision)
|
|
232
|
+
kind = decision && decision["kind"]
|
|
233
|
+
kind.nil? || kind.empty? ? "boolean" : kind
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def nonboolean_kind?(kind)
|
|
237
|
+
NONBOOLEAN_KINDS.include?(kind)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def validate_alternatives(alternatives)
|
|
241
|
+
fail_with("alternatives must be an array") unless alternatives.is_a?(Array)
|
|
242
|
+
ids = unique_ids(alternatives, "id", "alternative")
|
|
243
|
+
alternatives.each do |alternative|
|
|
244
|
+
fail_with("alternative index must be an integer") unless alternative["index"].is_a?(Integer)
|
|
245
|
+
fail_with("alternative expression must be a string") unless alternative["expression"].is_a?(String)
|
|
246
|
+
%w[line column byte_start byte_length].each do |field|
|
|
247
|
+
validate_integer_field(alternative, field, nullable: true)
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
indexes = alternatives.map { |alternative| alternative["index"] }
|
|
251
|
+
fail_with("alternative indexes must be consecutive") unless indexes.sort == (0...alternatives.length).to_a
|
|
252
|
+
ids
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def validate_nonboolean_analysis(analysis_decision, inventory_decision)
|
|
256
|
+
conditions = analysis_decision["conditions"]
|
|
257
|
+
fail_with("nonboolean conditions must be empty") unless conditions.nil? || conditions == []
|
|
258
|
+
coverage = analysis_decision["coverage"]
|
|
259
|
+
if coverage.nil?
|
|
260
|
+
return unless strict_flow_decision?(inventory_decision)
|
|
261
|
+
|
|
262
|
+
fail_with("complete flow analysis requires coverage")
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
fail_with("flow coverage must be an object") unless hash_with_string_keys?(coverage)
|
|
266
|
+
fail_with("alternative coverage must be an object") unless hash_with_string_keys?(coverage["alternative"])
|
|
267
|
+
validate_alternative_coverage(coverage["alternative"], inventory_decision)
|
|
268
|
+
mcdc = coverage["mcdc"]
|
|
269
|
+
if mcdc.nil?
|
|
270
|
+
fail_with("flow MC/DC status must be explicit") if strict_flow_decision?(inventory_decision)
|
|
271
|
+
return
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
allowed = if inventory_decision["support_status"].to_s.upcase == "UNSUPPORTED"
|
|
275
|
+
%w[not_applicable unsupported]
|
|
276
|
+
else
|
|
277
|
+
["not_applicable"]
|
|
278
|
+
end
|
|
279
|
+
fail_with("nonboolean MC/DC must be not_applicable") unless hash_with_string_keys?(mcdc) &&
|
|
280
|
+
allowed.include?(mcdc["status"])
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def validate_alternative_coverage(coverage, decision)
|
|
284
|
+
allowed_statuses = %w[covered partial unexecuted]
|
|
285
|
+
allowed_statuses << "unsupported" if decision["support_status"].to_s.upcase == "UNSUPPORTED"
|
|
286
|
+
fail_with("invalid alternative coverage status") unless allowed_statuses.include?(coverage["status"])
|
|
287
|
+
%w[covered_alternatives required_alternatives].each do |field|
|
|
288
|
+
validate_integer_field(coverage, field)
|
|
289
|
+
end
|
|
290
|
+
expected = decision.fetch("alternatives")
|
|
291
|
+
expected_for_coverage = coverage["status"] == "unsupported" ? [] : expected
|
|
292
|
+
if strict_flow_decision?(decision)
|
|
293
|
+
%w[covered_alternatives required_alternatives].each do |field|
|
|
294
|
+
fail_with("#{field} must be an integer") unless coverage[field].is_a?(Integer)
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
unless coverage["required_alternatives"] == expected_for_coverage.length
|
|
298
|
+
fail_with("alternative coverage count mismatch")
|
|
299
|
+
end
|
|
300
|
+
if strict_flow_decision?(decision)
|
|
301
|
+
fail_with("alternative coverage count must be nonnegative") unless coverage["covered_alternatives"] >= 0 &&
|
|
302
|
+
coverage["required_alternatives"] >= 0
|
|
303
|
+
fail_with("covered alternatives exceed required alternatives") unless coverage["covered_alternatives"] <=
|
|
304
|
+
coverage["required_alternatives"]
|
|
305
|
+
end
|
|
306
|
+
alternatives = coverage["alternatives"]
|
|
307
|
+
fail_with("alternative coverage alternatives must be an array") unless alternatives.is_a?(Array)
|
|
308
|
+
seen = {}
|
|
309
|
+
alternatives.each do |row|
|
|
310
|
+
fail_with("invalid alternative coverage row") unless hash_with_string_keys?(row)
|
|
311
|
+
id = row["alternative_id"]
|
|
312
|
+
inventory = expected_for_coverage.find { |alternative| alternative["id"] == id }
|
|
313
|
+
fail_with("unknown alternative coverage id") unless inventory && !seen.key?(id)
|
|
314
|
+
seen[id] = true
|
|
315
|
+
unless row["index"].is_a?(Integer) && row["index"] == inventory["index"]
|
|
316
|
+
fail_with("alternative coverage index mismatch")
|
|
317
|
+
end
|
|
318
|
+
fail_with("alternative coverage expression must be a string") unless row["expression"].is_a?(String)
|
|
319
|
+
%w[selected not_selected skipped].each do |state|
|
|
320
|
+
if strict_flow_decision?(decision)
|
|
321
|
+
validate_alternative_evidence(row[state], decision, inventory["index"], state)
|
|
322
|
+
else
|
|
323
|
+
validate_alternative_evidence(row[state])
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
fail_with("alternative coverage missing rows") unless seen.keys.sort == expected_for_coverage.map { |alternative|
|
|
328
|
+
alternative["id"]
|
|
329
|
+
}.sort
|
|
330
|
+
missing = coverage["missing_alternatives"]
|
|
331
|
+
fail_with("missing alternatives must be an array of strings") unless strings?(missing)
|
|
332
|
+
if strict_flow_decision?(decision)
|
|
333
|
+
expected_missing = alternatives.filter_map do |row|
|
|
334
|
+
row["alternative_id"] unless row.dig("selected", "observed")
|
|
335
|
+
end
|
|
336
|
+
fail_with("missing alternatives do not match selected evidence") unless missing.sort == expected_missing.sort
|
|
337
|
+
covered = alternatives.count { |row| row.dig("selected", "observed") }
|
|
338
|
+
unless coverage["covered_alternatives"] == covered
|
|
339
|
+
fail_with("covered alternatives do not match selected evidence")
|
|
340
|
+
end
|
|
341
|
+
expected_status = if covered.zero?
|
|
342
|
+
"unexecuted"
|
|
343
|
+
elsif covered == expected.length
|
|
344
|
+
"covered"
|
|
345
|
+
else
|
|
346
|
+
"partial"
|
|
347
|
+
end
|
|
348
|
+
fail_with("alternative coverage status does not match evidence") unless coverage["status"] == expected_status
|
|
349
|
+
end
|
|
350
|
+
fail_with("unknown missing alternative") unless (missing - expected.map do |alternative|
|
|
351
|
+
alternative["id"]
|
|
352
|
+
end).empty?
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def validate_alternative_evidence(evidence, decision = nil, index = nil, state = nil)
|
|
356
|
+
fail_with("alternative evidence must be an object") unless hash_with_string_keys?(evidence)
|
|
357
|
+
fail_with("alternative evidence observed must be boolean") unless [true, false].include?(evidence["observed"])
|
|
358
|
+
fail_with("alternative evidence vector_ids must be strings") unless strings?(evidence["vector_ids"])
|
|
359
|
+
unless evidence["vector_ids"].uniq.length == evidence["vector_ids"].length
|
|
360
|
+
fail_with("duplicate alternative evidence vector")
|
|
361
|
+
end
|
|
362
|
+
fail_with("unknown alternative evidence vector") unless (evidence["vector_ids"] - @vector_ids).empty?
|
|
363
|
+
fail_with("alternative evidence test_ids must be strings") unless strings?(evidence["test_ids"])
|
|
364
|
+
unless evidence["test_ids"].uniq.length == evidence["test_ids"].length
|
|
365
|
+
fail_with("duplicate alternative evidence test")
|
|
366
|
+
end
|
|
367
|
+
fail_with("unknown alternative evidence test") unless (evidence["test_ids"] - @test_ids).empty?
|
|
368
|
+
validate_integer_field(evidence, "unattributed_count")
|
|
369
|
+
return unless decision
|
|
370
|
+
|
|
371
|
+
vectors = evidence["vector_ids"].map { |id| @vectors_by_id.fetch(id) }
|
|
372
|
+
fail_with("alternative evidence references another decision") unless vectors.all? do |vector|
|
|
373
|
+
vector["decision_id"] == decision["id"]
|
|
374
|
+
end
|
|
375
|
+
expected_value = lambda do |vector|
|
|
376
|
+
value = vector["values"][index]
|
|
377
|
+
if state == "selected"
|
|
378
|
+
value == true
|
|
379
|
+
else
|
|
380
|
+
state == "not_selected" ? value == false : value.nil?
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
fail_with("alternative evidence state mismatch") unless vectors.all?(&expected_value)
|
|
384
|
+
expected_vectors = @vectors_by_id.values.select do |vector|
|
|
385
|
+
vector["decision_id"] == decision["id"] && expected_value.call(vector)
|
|
386
|
+
end
|
|
387
|
+
expected_vector_ids = expected_vectors.map { |vector| vector["id"] }.sort
|
|
388
|
+
fail_with("alternative evidence is incomplete") unless evidence["vector_ids"].sort == expected_vector_ids
|
|
389
|
+
expected_tests = vectors.flat_map { |vector| vector["test_ids"] }.uniq.sort
|
|
390
|
+
fail_with("alternative evidence test owners mismatch") unless evidence["test_ids"].sort == expected_tests
|
|
391
|
+
expected_unattributed = vectors.sum { |vector| vector.fetch("unattributed_count", 0).to_i }
|
|
392
|
+
fail_with("alternative evidence unattributed count mismatch") unless evidence.fetch("unattributed_count",
|
|
393
|
+
0) == expected_unattributed
|
|
394
|
+
fail_with("alternative evidence observed mismatch") unless evidence["observed"] == !vectors.empty?
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
def validate_flow_vector(vector, decision)
|
|
398
|
+
values = vector["values"]
|
|
399
|
+
valid = if decision_kind(decision) == "implicit"
|
|
400
|
+
vector["outcome"] == true && values.length == 2 && values.all? do |value|
|
|
401
|
+
[true, false].include?(value)
|
|
402
|
+
end && values.count(true) == 1
|
|
403
|
+
else
|
|
404
|
+
observations = values.each_with_index.filter_map { |value, index| [index, value] unless value.nil? }
|
|
405
|
+
vector["outcome"] == true &&
|
|
406
|
+
observations.any? &&
|
|
407
|
+
observations.each_with_index.all? do |(index, value), position|
|
|
408
|
+
index == position && value == (position == observations.length - 1)
|
|
409
|
+
end
|
|
410
|
+
end
|
|
411
|
+
fail_with("invalid flow vector trace") unless valid
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
def strict_flow_decision?(decision)
|
|
415
|
+
@schema_version == "1.2" &&
|
|
416
|
+
nonboolean_kind?(decision_kind(decision)) &&
|
|
417
|
+
decision["support_status"].to_s.upcase != "UNSUPPORTED"
|
|
418
|
+
end
|
|
419
|
+
|
|
191
420
|
def validate_completeness(completeness)
|
|
192
421
|
fail_with("completeness must be an object") unless hash_with_string_keys?(completeness)
|
|
193
422
|
COMPLETENESS_FIELDS.each do |field|
|
|
@@ -277,9 +506,17 @@ module Branchproof
|
|
|
277
506
|
analysis = @document["analysis"]
|
|
278
507
|
return unless analysis && analysis.dig("completeness", "analysis") == true
|
|
279
508
|
|
|
280
|
-
|
|
509
|
+
analysis_by_id = analysis["decisions"].to_h { |decision| [decision["decision_id"], decision] }
|
|
281
510
|
@supported_ids.each do |id|
|
|
282
|
-
|
|
511
|
+
inventory_decision = @decisions_by_id.fetch(id)
|
|
512
|
+
analysis_decision = analysis_by_id[id]
|
|
513
|
+
if strict_flow_decision?(inventory_decision)
|
|
514
|
+
fail_with("complete analysis is missing flow decision") unless analysis_decision
|
|
515
|
+
validate_nonboolean_analysis(analysis_decision, inventory_decision)
|
|
516
|
+
end
|
|
517
|
+
actual = Array(analysis_decision && analysis_decision["condition_results"]).map do |result|
|
|
518
|
+
result["condition_id"]
|
|
519
|
+
end.sort
|
|
283
520
|
fail_with("complete analysis is missing condition results") unless actual == @conditions_by_decision[id].sort
|
|
284
521
|
end
|
|
285
522
|
end
|
|
@@ -364,3 +601,5 @@ module Branchproof
|
|
|
364
601
|
end
|
|
365
602
|
|
|
366
603
|
# rubocop:enable Metrics/ClassLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
604
|
+
|
|
605
|
+
# rubocop:enable Metrics/BlockLength
|
data/lib/branchproof/source.rb
CHANGED
|
@@ -3,10 +3,13 @@
|
|
|
3
3
|
require "digest"
|
|
4
4
|
require "pathname"
|
|
5
5
|
require "prism"
|
|
6
|
+
require_relative "decision_syntax"
|
|
6
7
|
|
|
7
8
|
module Branchproof
|
|
8
9
|
# Inventories supported condition and decision occurrences from Ruby files.
|
|
9
10
|
class Source
|
|
11
|
+
include DecisionSyntax
|
|
12
|
+
|
|
10
13
|
attr_reader :root, :limits
|
|
11
14
|
|
|
12
15
|
def initialize(root:, limits:)
|
|
@@ -98,10 +101,98 @@ module Branchproof
|
|
|
98
101
|
end
|
|
99
102
|
|
|
100
103
|
def decisions_for(program, bytes, source_id, file_reasons = [], encoding = "UTF-8")
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
104
|
+
specs = []
|
|
105
|
+
inventoried_boolean_nodes = {}.compare_by_identity
|
|
106
|
+
defined_ranges = []
|
|
107
|
+
pattern_guard_nodes = pattern_guard_nodes_for(program)
|
|
108
|
+
|
|
109
|
+
walk(program) do |node|
|
|
110
|
+
defined_ranges << node.location if node.is_a?(Prism::DefinedNode)
|
|
111
|
+
if decision_node?(node)
|
|
112
|
+
predicate = node.predicate
|
|
113
|
+
next unless predicate
|
|
114
|
+
|
|
115
|
+
predicate = unwrap_predicate(predicate)
|
|
116
|
+
context = pattern_guard_nodes[node] ? "pattern_guard" : decision_context(node, bytes)
|
|
117
|
+
specs << { node: node, predicate: predicate, context: context }
|
|
118
|
+
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|
|
|
122
|
+
predicate = unwrap_predicate(predicate)
|
|
123
|
+
specs << { node: when_node, predicate: predicate, context: "case_when" }
|
|
124
|
+
mark_semantic_boolean_nodes(predicate, inventoried_boolean_nodes)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Boolean expressions nested in an atomic expression (for example, a call
|
|
131
|
+
# 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)
|
|
134
|
+
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"
|
|
142
|
+
specs << { node: nil, predicate: node, context: context,
|
|
143
|
+
additional_reasons: additional_reasons }
|
|
144
|
+
mark_semantic_boolean_nodes(node, inventoried_boolean_nodes)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
ordered_specs = specs.each_with_index.sort_by do |(spec, index)|
|
|
148
|
+
[spec[:predicate].location.start_offset, index]
|
|
149
|
+
end.map(&:first)
|
|
150
|
+
seen_ranges = {}
|
|
151
|
+
boolean_decisions = ordered_specs.filter_map do |spec|
|
|
152
|
+
predicate = spec[:predicate]
|
|
153
|
+
range = [predicate.location.start_offset, predicate.location.length]
|
|
154
|
+
next if seen_ranges[range]
|
|
155
|
+
|
|
156
|
+
seen_ranges[range] = true
|
|
157
|
+
build_decision(spec[:node], bytes, source_id, file_reasons, encoding,
|
|
158
|
+
predicate: predicate, context: spec[:context],
|
|
159
|
+
additional_reasons: spec[:additional_reasons] || [])
|
|
160
|
+
end
|
|
161
|
+
boolean_decisions + flow_decisions_for(program, bytes, source_id, file_reasons, encoding)
|
|
162
|
+
end
|
|
163
|
+
|
|
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
|
|
169
|
+
|
|
170
|
+
super.map do |decision|
|
|
171
|
+
next decision unless range_within_defined_expression?(decision, defined_ranges)
|
|
172
|
+
|
|
173
|
+
decision.merge(
|
|
174
|
+
support_status: "UNSUPPORTED",
|
|
175
|
+
support_reasons: (Array(decision[:support_reasons]) + ["unsupported_defined_expression"]).uniq
|
|
176
|
+
)
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
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
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def range_within_defined_expression?(decision, defined_ranges)
|
|
192
|
+
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
|
|
105
196
|
end
|
|
106
197
|
end
|
|
107
198
|
|
|
@@ -111,23 +202,55 @@ module Branchproof
|
|
|
111
202
|
end
|
|
112
203
|
|
|
113
204
|
def decision_node?(node)
|
|
114
|
-
node.is_a?(Prism::IfNode) || node.is_a?(Prism::UnlessNode)
|
|
205
|
+
node.is_a?(Prism::IfNode) || node.is_a?(Prism::UnlessNode) ||
|
|
206
|
+
node.is_a?(Prism::WhileNode) || node.is_a?(Prism::UntilNode)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def boolean_node?(node)
|
|
210
|
+
node.is_a?(Prism::AndNode) || node.is_a?(Prism::OrNode)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def subjectless_case?(node)
|
|
214
|
+
node.is_a?(Prism::CaseNode) && node.predicate.nil?
|
|
115
215
|
end
|
|
116
216
|
|
|
117
|
-
def
|
|
118
|
-
|
|
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)
|
|
227
|
+
conditions = node.conditions
|
|
228
|
+
if conditions.is_a?(Array)
|
|
229
|
+
conditions
|
|
230
|
+
else
|
|
231
|
+
(conditions.respond_to?(:body) ? conditions.body : [])
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def decision_context(node, bytes)
|
|
236
|
+
return "unless" if node.is_a?(Prism::UnlessNode)
|
|
237
|
+
return "while" if node.is_a?(Prism::WhileNode)
|
|
238
|
+
return "until" if node.is_a?(Prism::UntilNode)
|
|
239
|
+
return "ternary" if node.if_keyword_loc.nil?
|
|
240
|
+
|
|
241
|
+
token = bytes.byteslice(node.if_keyword_loc.start_offset, node.if_keyword_loc.length)
|
|
242
|
+
token == "elsif" ? "elsif" : "if"
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def build_decision(node, bytes, source_id, file_reasons = [], encoding = "UTF-8", predicate: nil,
|
|
246
|
+
context: nil, additional_reasons: [])
|
|
247
|
+
original_predicate = node.respond_to?(:predicate) ? node.predicate : nil
|
|
248
|
+
predicate ||= unwrap_predicate(original_predicate)
|
|
249
|
+
context ||= decision_context(node, bytes)
|
|
119
250
|
leaves = []
|
|
120
251
|
tree = tree_for(predicate, bytes, leaves)
|
|
121
252
|
start_offset = predicate.location.start_offset
|
|
122
253
|
length = predicate.location.length
|
|
123
|
-
context = if node.is_a?(Prism::UnlessNode)
|
|
124
|
-
"unless"
|
|
125
|
-
elsif node.if_keyword_loc.nil?
|
|
126
|
-
"ternary"
|
|
127
|
-
else
|
|
128
|
-
token = bytes.byteslice(node.if_keyword_loc.start_offset, node.if_keyword_loc.length)
|
|
129
|
-
token == "elsif" ? "elsif" : "if"
|
|
130
|
-
end
|
|
131
254
|
opaque_ranges = leaves.filter_map { |leaf| leaf.delete(:_opaque_range) }
|
|
132
255
|
conditions = leaves.each_with_index.map do |leaf, index|
|
|
133
256
|
expression = text_value(leaf.delete(:_expression), "UTF-8")
|
|
@@ -142,8 +265,9 @@ module Branchproof
|
|
|
142
265
|
conditions = conditions.map do |condition|
|
|
143
266
|
condition.merge(id: Records.condition_id(decision_id, condition[:index]))
|
|
144
267
|
end
|
|
145
|
-
reasons = unsupported_reasons(predicate, bytes) + file_reasons
|
|
146
|
-
reasons << "
|
|
268
|
+
reasons = unsupported_reasons(predicate, bytes) + additional_reasons + file_reasons
|
|
269
|
+
reasons << "unsupported_case_splat" if predicate.is_a?(Prism::SplatNode)
|
|
270
|
+
reasons << "unsupported_control_expression" if ambiguous_parentheses?(original_predicate)
|
|
147
271
|
reasons << "condition_limit_exceeded" if conditions.length > @limits[:conditions_per_decision]
|
|
148
272
|
discovered_condition_count = conditions.length
|
|
149
273
|
if discovered_condition_count > @limits[:conditions_per_decision]
|
|
@@ -152,14 +276,37 @@ module Branchproof
|
|
|
152
276
|
end
|
|
153
277
|
support = reasons.empty? ? "SUPPORTED" : "UNSUPPORTED"
|
|
154
278
|
expression = text_value(bytes.byteslice(predicate.location.start_offset, predicate.location.length), encoding)
|
|
155
|
-
Records.build(id: decision_id, source_id: source_id,
|
|
156
|
-
|
|
279
|
+
Records.build(id: decision_id, source_id: source_id, kind: "boolean", context: context,
|
|
280
|
+
byte_start: start_offset, byte_length: length,
|
|
281
|
+
line: predicate.location.start_line, column: predicate.location.start_column,
|
|
157
282
|
expression: expression,
|
|
158
283
|
tree: tree,
|
|
159
284
|
conditions: conditions, discovered_condition_count: discovered_condition_count,
|
|
160
285
|
support_status: support, support_reasons: reasons.uniq, opaque_ranges: opaque_ranges)
|
|
161
286
|
end
|
|
162
287
|
|
|
288
|
+
def within_defined_expression?(node, defined_ranges)
|
|
289
|
+
location = node.location
|
|
290
|
+
defined_ranges.any? do |defined_location|
|
|
291
|
+
defined_location.start_offset <= location.start_offset &&
|
|
292
|
+
defined_location.end_offset >= location.end_offset
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def mark_semantic_boolean_nodes(node, inventoried_boolean_nodes)
|
|
297
|
+
node = unwrap_predicate(node)
|
|
298
|
+
case node
|
|
299
|
+
when Prism::AndNode, Prism::OrNode
|
|
300
|
+
inventoried_boolean_nodes[node] = true
|
|
301
|
+
mark_semantic_boolean_nodes(node.left, inventoried_boolean_nodes)
|
|
302
|
+
mark_semantic_boolean_nodes(node.right, inventoried_boolean_nodes)
|
|
303
|
+
when Prism::MatchPredicateNode
|
|
304
|
+
inventoried_boolean_nodes[node] = true
|
|
305
|
+
when Prism::CallNode
|
|
306
|
+
mark_semantic_boolean_nodes(node.receiver, inventoried_boolean_nodes) if unary_not?(node)
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
|
|
163
310
|
def tree_for(node, bytes, leaves)
|
|
164
311
|
node = unwrap_predicate(node)
|
|
165
312
|
case node
|
|
@@ -167,18 +314,34 @@ module Branchproof
|
|
|
167
314
|
Records.build(type: :and, left: tree_for(node.left, bytes, leaves), right: tree_for(node.right, bytes, leaves))
|
|
168
315
|
when Prism::OrNode
|
|
169
316
|
Records.build(type: :or, left: tree_for(node.left, bytes, leaves), right: tree_for(node.right, bytes, leaves))
|
|
317
|
+
when Prism::CallNode
|
|
318
|
+
if unary_not?(node)
|
|
319
|
+
Records.build(type: :not, child: tree_for(node.receiver, bytes, leaves))
|
|
320
|
+
else
|
|
321
|
+
leaf_for(node, bytes, leaves)
|
|
322
|
+
end
|
|
170
323
|
else
|
|
171
|
-
|
|
172
|
-
leaf = {
|
|
173
|
-
_expression: bytes.byteslice(location.start_offset, location.length), _location: location,
|
|
174
|
-
_literal_truth: literal_truth(node),
|
|
175
|
-
_opaque_range: opaque?(node) ? { start: location.start_offset, length: location.length } : nil
|
|
176
|
-
}
|
|
177
|
-
leaves << leaf
|
|
178
|
-
Records.build(type: :atom, index: leaves.length - 1)
|
|
324
|
+
leaf_for(node, bytes, leaves)
|
|
179
325
|
end
|
|
180
326
|
end
|
|
181
327
|
|
|
328
|
+
def leaf_for(node, bytes, leaves)
|
|
329
|
+
location = node.location
|
|
330
|
+
leaf = {
|
|
331
|
+
_expression: bytes.byteslice(location.start_offset, location.length), _location: location,
|
|
332
|
+
_literal_truth: literal_truth(node),
|
|
333
|
+
_opaque_range: if node.is_a?(Prism::CallNode) && node.name == :!
|
|
334
|
+
{ start: location.start_offset, length: location.length }
|
|
335
|
+
end
|
|
336
|
+
}
|
|
337
|
+
leaves << leaf
|
|
338
|
+
Records.build(type: :atom, index: leaves.length - 1)
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def unary_not?(node)
|
|
342
|
+
node.is_a?(Prism::CallNode) && node.name == :! && node.receiver && node.call_operator_loc.nil?
|
|
343
|
+
end
|
|
344
|
+
|
|
182
345
|
def literal_truth(node)
|
|
183
346
|
return true if node.is_a?(Prism::TrueNode)
|
|
184
347
|
return false if node.is_a?(Prism::FalseNode) || node.is_a?(Prism::NilNode)
|
|
@@ -186,10 +349,6 @@ module Branchproof
|
|
|
186
349
|
nil
|
|
187
350
|
end
|
|
188
351
|
|
|
189
|
-
def opaque?(node)
|
|
190
|
-
node.is_a?(Prism::CallNode) && node.name == :!
|
|
191
|
-
end
|
|
192
|
-
|
|
193
352
|
def unsupported_reasons(predicate, bytes)
|
|
194
353
|
reasons = []
|
|
195
354
|
walk(predicate) do |node|
|
|
@@ -197,13 +356,10 @@ module Branchproof
|
|
|
197
356
|
reasons << "unsupported_implicit_regexp"
|
|
198
357
|
end
|
|
199
358
|
reasons << "unsupported_flip_flop" if node.is_a?(Prism::FlipFlopNode)
|
|
359
|
+
reasons << "unsupported_defined_expression" if node.is_a?(Prism::DefinedNode)
|
|
200
360
|
reasons << "unsupported_heredoc" if node.respond_to?(:opening_loc) && node.opening_loc &&
|
|
201
361
|
bytes.byteslice(node.opening_loc.start_offset,
|
|
202
362
|
node.opening_loc.length).start_with?("<<")
|
|
203
|
-
if (node.is_a?(Prism::AndNode) || node.is_a?(Prism::OrNode)) && node.respond_to?(:operator_loc)
|
|
204
|
-
operator = bytes.byteslice(node.operator_loc.start_offset, node.operator_loc.length)
|
|
205
|
-
reasons << "unsupported_keyword_boolean" if %w[and or].include?(operator)
|
|
206
|
-
end
|
|
207
363
|
end
|
|
208
364
|
reasons
|
|
209
365
|
end
|
data/lib/branchproof/version.rb
CHANGED