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.
@@ -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
- schema = @document["schema_version"]
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 = @condition_counts.fetch(vector["decision_id"])
128
- fail_with("vector values do not match decision conditions") unless values.length == expected_length
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
- results = analysis["decisions"].to_h { |decision| [decision["decision_id"], decision["condition_results"]] }
509
+ analysis_by_id = analysis["decisions"].to_h { |decision| [decision["decision_id"], decision] }
281
510
  @supported_ids.each do |id|
282
- actual = Array(results[id]).map { |result| result["condition_id"] }.sort
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
@@ -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
- nodes = []
102
- walk(program) { |node| nodes << node if decision_node?(node) }
103
- nodes.sort_by { |node| node.location.start_offset }.map.with_index do |node, _|
104
- build_decision(node, bytes, source_id, file_reasons, encoding)
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 build_decision(node, bytes, source_id, file_reasons = [], encoding = "UTF-8")
118
- predicate = unwrap_predicate(node.predicate)
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 << "unsupported_control_expression" if ambiguous_parentheses?(node.predicate)
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, context: context, byte_start: start_offset,
156
- byte_length: length, line: predicate.location.start_line, column: predicate.location.start_column,
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
- location = node.location
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Branchproof
4
- VERSION = "0.6.0"
4
+ VERSION = "0.7.0"
5
5
  end