branchproof 0.6.0 → 0.8.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 +45 -0
- data/README.md +279 -27
- data/doc/Branchproof/Analyzer.md +2 -2
- data/doc/Branchproof/CLI.md +4 -0
- data/doc/Branchproof/ComparisonReport.md +4 -0
- data/doc/Branchproof/Constraints/Solver.md +21 -0
- data/doc/Branchproof/Constraints.md +98 -0
- data/doc/Branchproof/CoverageIndex.md +6 -0
- data/doc/Branchproof/DecisionSyntax.md +22 -0
- data/doc/Branchproof/DecisionTable.md +153 -0
- data/doc/Branchproof/FlowInstrumentation.md +7 -0
- data/doc/Branchproof/FocusedReport.md +5 -2
- data/doc/Branchproof/Instrumenter.md +1 -0
- data/doc/Branchproof/Loader.md +0 -4
- data/doc/Branchproof/MinitestAdapter.md +0 -3
- data/doc/Branchproof/Report.md +18 -0
- data/doc/Branchproof/Runtime.md +23 -0
- data/doc/Branchproof/RuntimeFlow.md +27 -0
- data/doc/Branchproof/SavedReport.md +21 -0
- data/doc/Branchproof/Source.md +1 -0
- data/doc/Branchproof.md +9 -2
- data/doc/CHANGELOG.md +45 -0
- data/doc/README.md +279 -27
- data/lib/branchproof/analyzer.rb +222 -54
- data/lib/branchproof/cli.rb +28 -19
- data/lib/branchproof/comparison.rb +207 -19
- data/lib/branchproof/comparison_report.rb +49 -1
- data/lib/branchproof/constraints.rb +363 -0
- data/lib/branchproof/coverage_index.rb +120 -3
- data/lib/branchproof/decision_syntax.rb +310 -0
- data/lib/branchproof/decision_table.rb +377 -0
- data/lib/branchproof/evidence.rb +101 -36
- data/lib/branchproof/flow_instrumentation.rb +107 -0
- data/lib/branchproof/focused_report.rb +161 -26
- data/lib/branchproof/instrumenter.rb +65 -39
- data/lib/branchproof/limits.rb +4 -1
- data/lib/branchproof/loader.rb +18 -6
- data/lib/branchproof/minimizer.rb +18 -13
- data/lib/branchproof/minitest_adapter.rb +11 -16
- data/lib/branchproof/records.rb +2 -0
- data/lib/branchproof/report.rb +470 -66
- data/lib/branchproof/runtime.rb +25 -30
- data/lib/branchproof/runtime_flow.rb +58 -0
- data/lib/branchproof/saved_report.rb +438 -13
- data/lib/branchproof/source.rb +239 -41
- data/lib/branchproof/version.rb +1 -1
- data/lib/branchproof/worker.rb +1 -4
- data/lib/branchproof.rb +2 -0
- data/llms.txt +9 -2
- data/sig/branchproof.rbs +54 -1
- metadata +12 -1
|
@@ -1,13 +1,25 @@
|
|
|
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"
|
|
8
|
+
# rubocop:disable-next Lint/RedundantRequireStatement -- supports standalone core entry
|
|
9
|
+
require "set"
|
|
10
|
+
require_relative "decision_table"
|
|
6
11
|
|
|
7
12
|
module Branchproof
|
|
8
13
|
# Reads and validates a persisted JSON report without loading the project.
|
|
9
14
|
class SavedReport
|
|
10
|
-
SUPPORTED_SCHEMAS = %w[1.0 1.1].freeze
|
|
15
|
+
SUPPORTED_SCHEMAS = %w[1.0 1.1 1.2 1.3].freeze
|
|
16
|
+
STRICT_FLOW_SCHEMAS = %w[1.2 1.3].freeze
|
|
17
|
+
DECISION_TABLE_STATUSES = %w[calculated not_calculated].freeze
|
|
18
|
+
RULE_CONDITION_VALUES = DecisionTable::CONDITION_VALUES
|
|
19
|
+
RULE_COVERAGE_STATUSES = DecisionTable::COVERAGE_STATUSES
|
|
20
|
+
RULE_REACHABILITY_STATUSES = DecisionTable::REACHABILITY_STATUSES
|
|
21
|
+
DECISION_KINDS = %w[boolean implicit multiway pattern exception].freeze
|
|
22
|
+
NONBOOLEAN_KINDS = (DECISION_KINDS - ["boolean"]).freeze
|
|
11
23
|
CRITERION_VERSION = "masking_occurrence_v1"
|
|
12
24
|
REQUIRED_FIELDS = %w[schema_version tool_version criterion_version runtime run_ids source_inventory baseline
|
|
13
25
|
observations analysis minima metrics diagnostics completeness].freeze
|
|
@@ -33,7 +45,8 @@ module Branchproof
|
|
|
33
45
|
|
|
34
46
|
def validate!
|
|
35
47
|
fail_with("document must be an object") unless hash_with_string_keys?(@document)
|
|
36
|
-
|
|
48
|
+
@schema_version = @document["schema_version"]
|
|
49
|
+
schema = @schema_version
|
|
37
50
|
fail_with("unsupported schema version") unless SUPPORTED_SCHEMAS.include?(schema)
|
|
38
51
|
missing = REQUIRED_FIELDS.reject { |field| @document.key?(field) }
|
|
39
52
|
fail_with("missing field: #{missing.first}") unless missing.empty?
|
|
@@ -66,6 +79,9 @@ module Branchproof
|
|
|
66
79
|
decisions.each do |decision|
|
|
67
80
|
fail_with("invalid decision") unless hash_with_string_keys?(decision)
|
|
68
81
|
fail_with("unknown decision source") unless source_ids.include?(decision["source_id"])
|
|
82
|
+
kind = decision_kind(decision)
|
|
83
|
+
fail_with("invalid decision kind") unless DECISION_KINDS.include?(kind)
|
|
84
|
+
validate_string_field(decision, "context", nullable: true)
|
|
69
85
|
validate_string_field(decision, "expression")
|
|
70
86
|
validate_integer_field(decision, "line")
|
|
71
87
|
conditions = decision["conditions"]
|
|
@@ -81,9 +97,17 @@ module Branchproof
|
|
|
81
97
|
fail_with("condition indexes must be consecutive") unless conditions.map do |condition|
|
|
82
98
|
condition["index"]
|
|
83
99
|
end.sort == (0...conditions.length).to_a
|
|
100
|
+
alternatives = decision["alternatives"]
|
|
101
|
+
if nonboolean_kind?(kind)
|
|
102
|
+
fail_with("nonboolean decision tree must be null") unless decision.key?("tree") && decision["tree"].nil?
|
|
103
|
+
validate_alternatives(alternatives)
|
|
104
|
+
elsif alternatives && (!alternatives.is_a?(Array) || !alternatives.empty?)
|
|
105
|
+
fail_with("boolean decision alternatives must be empty")
|
|
106
|
+
end
|
|
84
107
|
end
|
|
85
108
|
@source_ids = source_ids
|
|
86
109
|
@decision_ids = decision_ids
|
|
110
|
+
@decisions_by_id = decisions.to_h { |decision| [decision["id"], decision] }
|
|
87
111
|
@condition_ids = decisions.flat_map do |decision|
|
|
88
112
|
Array(decision["conditions"]).map do |condition|
|
|
89
113
|
condition["id"]
|
|
@@ -91,7 +115,7 @@ module Branchproof
|
|
|
91
115
|
end
|
|
92
116
|
fail_with("duplicate condition id") unless @condition_ids.uniq.length == @condition_ids.length
|
|
93
117
|
@conditions_by_decision = decisions.to_h do |decision|
|
|
94
|
-
[decision["id"], Array(decision["conditions"]).
|
|
118
|
+
[decision["id"], Array(decision["conditions"]).to_set { |condition| condition["id"] }]
|
|
95
119
|
end
|
|
96
120
|
sources.each do |source|
|
|
97
121
|
validate_string_field(source, "relative_path", nullable: true)
|
|
@@ -102,6 +126,18 @@ module Branchproof
|
|
|
102
126
|
paths = sources.map { |source| source["relative_path"] }.compact
|
|
103
127
|
fail_with("duplicate source relative path") unless paths.uniq.length == paths.length
|
|
104
128
|
@condition_counts = decisions.to_h { |decision| [decision["id"], decision["conditions"].length] }
|
|
129
|
+
@alternative_counts = decisions.to_h do |decision|
|
|
130
|
+
[decision["id"], nonboolean_kind?(decision_kind(decision)) ? decision.fetch("alternatives").length : 0]
|
|
131
|
+
end
|
|
132
|
+
@alternative_ids_by_decision = decisions.to_h do |decision|
|
|
133
|
+
[decision["id"], if nonboolean_kind?(decision_kind(decision))
|
|
134
|
+
decision.fetch("alternatives").map do |alternative|
|
|
135
|
+
alternative["id"]
|
|
136
|
+
end
|
|
137
|
+
else
|
|
138
|
+
[]
|
|
139
|
+
end]
|
|
140
|
+
end
|
|
105
141
|
@supported_ids = decisions.filter_map do |decision|
|
|
106
142
|
decision["id"] unless decision["support_status"] == "UNSUPPORTED"
|
|
107
143
|
end
|
|
@@ -114,21 +150,28 @@ module Branchproof
|
|
|
114
150
|
fail_with("tests must be an array") unless tests.is_a?(Array)
|
|
115
151
|
fail_with("vectors must be an array") unless vectors.is_a?(Array)
|
|
116
152
|
@test_ids = unique_ids(tests, "id", "test")
|
|
153
|
+
@test_id_set = @test_ids.to_set
|
|
117
154
|
tests.each { |test| validate_test(test) }
|
|
118
155
|
vector_ids = unique_ids(vectors, "id", "vector")
|
|
119
156
|
vectors.each do |vector|
|
|
120
157
|
fail_with("invalid vector") unless hash_with_string_keys?(vector)
|
|
121
|
-
fail_with("unknown vector decision") unless @
|
|
158
|
+
fail_with("unknown vector decision") unless @decisions_by_id.key?(vector["decision_id"])
|
|
159
|
+
decision = @decisions_by_id.fetch(vector["decision_id"])
|
|
122
160
|
values = vector["values"]
|
|
123
161
|
valid_values = values.is_a?(Array) && values.all? do |value|
|
|
124
162
|
value.nil? || value == true || value == false
|
|
125
163
|
end
|
|
126
164
|
fail_with("vector values must be an array of booleans or null") unless valid_values
|
|
127
|
-
expected_length = @
|
|
128
|
-
|
|
165
|
+
expected_length = if @alternative_counts.fetch(vector["decision_id"]).positive?
|
|
166
|
+
@alternative_counts.fetch(vector["decision_id"])
|
|
167
|
+
else
|
|
168
|
+
@condition_counts.fetch(vector["decision_id"])
|
|
169
|
+
end
|
|
170
|
+
fail_with("vector values do not match decision dimensions") unless values.length == expected_length
|
|
129
171
|
fail_with("vector outcome must be boolean") unless [true, false].include?(vector["outcome"])
|
|
172
|
+
validate_flow_vector(vector, decision) if strict_flow_decision?(decision)
|
|
130
173
|
fail_with("unknown vector test") unless strings?(vector["test_ids"]) && vector["test_ids"].all? do |id|
|
|
131
|
-
@
|
|
174
|
+
@test_id_set.include?(id)
|
|
132
175
|
end
|
|
133
176
|
validate_phases(vector)
|
|
134
177
|
validate_integer_field(vector, "count")
|
|
@@ -136,6 +179,8 @@ module Branchproof
|
|
|
136
179
|
end
|
|
137
180
|
@vector_ids = vector_ids
|
|
138
181
|
@vector_decision_by_id = vectors.to_h { |vector| [vector["id"], vector["decision_id"]] }
|
|
182
|
+
@vectors_by_id = vectors.to_h { |vector| [vector["id"], vector] }
|
|
183
|
+
@vectors_by_decision = vectors.group_by { |vector| vector["decision_id"] }
|
|
139
184
|
aborts = observations["abort_counts"]
|
|
140
185
|
return if aborts.nil? || aborts.is_a?(Integer) || (aborts.is_a?(Hash) && aborts.values.all?(Integer))
|
|
141
186
|
|
|
@@ -164,10 +209,18 @@ module Branchproof
|
|
|
164
209
|
decisions.each do |decision|
|
|
165
210
|
fail_with("invalid analysis decision") unless hash_with_string_keys?(decision)
|
|
166
211
|
id = decision["decision_id"]
|
|
167
|
-
fail_with("unknown analysis decision") unless @
|
|
212
|
+
fail_with("unknown analysis decision") unless @decisions_by_id.key?(id) && !seen.key?(id)
|
|
168
213
|
seen[id] = true
|
|
169
214
|
results = decision["condition_results"]
|
|
170
215
|
fail_with("condition results must be an array") unless results.is_a?(Array)
|
|
216
|
+
inventory_decision = @decisions_by_id[id]
|
|
217
|
+
if nonboolean_kind?(decision_kind(inventory_decision))
|
|
218
|
+
fail_with("nonboolean condition results must be empty") unless results.empty?
|
|
219
|
+
validate_nonboolean_analysis(decision, inventory_decision)
|
|
220
|
+
elsif decision.key?("decision_table")
|
|
221
|
+
fail_with("decision tables are unsupported in legacy report schemas") unless @schema_version == "1.3"
|
|
222
|
+
validate_decision_table(decision["decision_table"], inventory_decision)
|
|
223
|
+
end
|
|
171
224
|
seen_conditions = {}
|
|
172
225
|
results.each do |result|
|
|
173
226
|
condition_id = result["condition_id"] if hash_with_string_keys?(result)
|
|
@@ -181,13 +234,375 @@ module Branchproof
|
|
|
181
234
|
|
|
182
235
|
pair = result["canonical_pair"]
|
|
183
236
|
valid_pair = pair.is_a?(Array) && pair.length == 2 && pair.uniq.length == 2 && pair.all? do |id|
|
|
184
|
-
@
|
|
237
|
+
@vectors_by_id.key?(id) && @vector_decision_by_id[id] == decision["decision_id"]
|
|
185
238
|
end
|
|
186
239
|
fail_with("canonical pair must reference vectors") unless valid_pair
|
|
187
240
|
end
|
|
188
241
|
end
|
|
189
242
|
end
|
|
190
243
|
|
|
244
|
+
# The persisted decision table must describe the same decision it was
|
|
245
|
+
# derived from, so a saved report can be reported on and compared offline.
|
|
246
|
+
def validate_decision_table(table, decision)
|
|
247
|
+
fail_with("decision_table must be an object") unless hash_with_string_keys?(table)
|
|
248
|
+
fail_with("decision_table status is invalid") unless DECISION_TABLE_STATUSES.include?(table["status"])
|
|
249
|
+
fail_with("decision_table decision id mismatch") unless table["decision_id"] == decision["id"]
|
|
250
|
+
%w[schema_version constraint_analysis_version].each do |field|
|
|
251
|
+
fail_with("decision_table #{field} must be an integer") unless table[field].is_a?(Integer)
|
|
252
|
+
end
|
|
253
|
+
unless table["schema_version"] == DecisionTable::SCHEMA_VERSION
|
|
254
|
+
fail_with("unsupported decision_table schema version")
|
|
255
|
+
end
|
|
256
|
+
solver_version = table["constraint_analysis_version"]
|
|
257
|
+
supported_solver = [1, DecisionTable::CONSTRAINT_ANALYSIS_VERSION].include?(solver_version) &&
|
|
258
|
+
solver_version <= DecisionTable::CONSTRAINT_ANALYSIS_VERSION
|
|
259
|
+
fail_with("unsupported decision_table constraint analysis version") unless supported_solver
|
|
260
|
+
validate_string_field(table, "reason", nullable: true)
|
|
261
|
+
unless DecisionTable::COVERAGE_SUMMARY_STATUSES.include?(table["coverage_status"])
|
|
262
|
+
fail_with("invalid decision_table coverage status")
|
|
263
|
+
end
|
|
264
|
+
%w[generated_rules impossible_rules required_rules covered_rules missing_rules].each do |field|
|
|
265
|
+
unless table[field].is_a?(Integer) && table[field] >= 0
|
|
266
|
+
fail_with("decision_table #{field} must be a nonnegative integer")
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
rules = table["rules"]
|
|
270
|
+
fail_with("decision_table rules must be an array") unless rules.is_a?(Array)
|
|
271
|
+
unique_ids(rules, "id", "decision table rule")
|
|
272
|
+
indexes = rules.map { |rule| rule["index"] }
|
|
273
|
+
fail_with("decision table rule indexes must be consecutive") unless indexes.sort == (0...rules.length).to_a
|
|
274
|
+
condition_count = decision["conditions"].length
|
|
275
|
+
rules.each { |rule| validate_decision_table_rule(rule, decision, condition_count) }
|
|
276
|
+
validate_decision_table_counts(table, rules)
|
|
277
|
+
validate_decision_table_summary(table, rules)
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def validate_decision_table_rule(rule, decision, condition_count)
|
|
281
|
+
fail_with("invalid decision table rule") unless hash_with_string_keys?(rule)
|
|
282
|
+
validate_string_field(rule, "label")
|
|
283
|
+
validate_integer_field(rule, "index")
|
|
284
|
+
unless rule["index"].is_a?(Integer) && rule["index"] >= 0
|
|
285
|
+
fail_with("decision table rule index must be nonnegative")
|
|
286
|
+
end
|
|
287
|
+
fail_with("decision table rule label does not match its index") unless rule["label"] == "R#{rule["index"] + 1}"
|
|
288
|
+
conditions = rule["conditions"]
|
|
289
|
+
valid = conditions.is_a?(Array) && conditions.length == condition_count &&
|
|
290
|
+
conditions.all? { |value| RULE_CONDITION_VALUES.include?(value) }
|
|
291
|
+
fail_with("decision table rule conditions do not match the decision") unless valid
|
|
292
|
+
fail_with("decision table rule outcome must be boolean") unless [true, false].include?(rule["outcome"])
|
|
293
|
+
expected_id = DecisionTable.rule_id(decision["id"], conditions, rule["outcome"])
|
|
294
|
+
unless rule["id"] == expected_id
|
|
295
|
+
fail_with("decision table rule id does not match its decision, conditions, and outcome")
|
|
296
|
+
end
|
|
297
|
+
fail_with("invalid decision table rule coverage") unless RULE_COVERAGE_STATUSES.include?(rule["coverage"])
|
|
298
|
+
unless RULE_REACHABILITY_STATUSES.include?(rule["reachability"])
|
|
299
|
+
fail_with("invalid decision table rule reachability")
|
|
300
|
+
end
|
|
301
|
+
validate_string_field(rule, "reachability_reason", nullable: true)
|
|
302
|
+
if rule["reachability"] == "statically_impossible" && !rule["reachability_reason"].is_a?(String)
|
|
303
|
+
fail_with("statically impossible rule requires a reason code")
|
|
304
|
+
end
|
|
305
|
+
if %w[observed unknown].include?(rule["reachability"]) && !rule["reachability_reason"].nil?
|
|
306
|
+
fail_with("observed or unknown rule must not have a reachability reason")
|
|
307
|
+
end
|
|
308
|
+
fail_with("decision table rule tests must be strings") unless strings?(rule["tests"])
|
|
309
|
+
fail_with("unknown decision table rule test") unless rule["tests"].all? { |id| @test_id_set.include?(id) }
|
|
310
|
+
fail_with("decision table rule vector_ids must be strings") unless strings?(rule["vector_ids"])
|
|
311
|
+
unless rule["vector_ids"].uniq.length == rule["vector_ids"].length
|
|
312
|
+
fail_with("duplicate decision table rule vector")
|
|
313
|
+
end
|
|
314
|
+
fail_with("unknown decision table rule vector") unless rule["vector_ids"].all? do |id|
|
|
315
|
+
@vector_decision_by_id[id] == decision["id"]
|
|
316
|
+
end
|
|
317
|
+
validate_decision_table_evidence(rule, decision)
|
|
318
|
+
validate_integer_field(rule, "unattributed_count")
|
|
319
|
+
unless rule["unattributed_count"].is_a?(Integer) && rule["unattributed_count"] >= 0
|
|
320
|
+
fail_with("decision table rule unattributed count must be nonnegative")
|
|
321
|
+
end
|
|
322
|
+
return unless rule["coverage"] == "covered" && rule["vector_ids"].empty?
|
|
323
|
+
|
|
324
|
+
fail_with("covered decision table rule requires evidence")
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def validate_decision_table_counts(table, rules)
|
|
328
|
+
if table["status"] == "not_calculated"
|
|
329
|
+
fail_with("not_calculated decision table must not contain rules") unless rules.empty?
|
|
330
|
+
%w[generated_rules impossible_rules required_rules covered_rules missing_rules].each do |field|
|
|
331
|
+
fail_with("not_calculated decision table #{field} must be zero") unless table[field].zero?
|
|
332
|
+
end
|
|
333
|
+
return
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
fail_with("decision_table generated_rules mismatch") unless table["generated_rules"] == rules.length
|
|
337
|
+
impossible = rules.count { |rule| rule["coverage"] == "excluded" }
|
|
338
|
+
covered = rules.count { |rule| rule["coverage"] == "covered" }
|
|
339
|
+
fail_with("decision_table impossible_rules mismatch") unless table["impossible_rules"] == impossible
|
|
340
|
+
fail_with("decision_table required_rules mismatch") unless table["required_rules"] == rules.length - impossible
|
|
341
|
+
fail_with("decision_table covered_rules mismatch") unless table["covered_rules"] == covered
|
|
342
|
+
return if table["missing_rules"] == table["required_rules"] - table["covered_rules"]
|
|
343
|
+
|
|
344
|
+
fail_with("decision_table missing_rules mismatch")
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def validate_decision_table_summary(table, rules)
|
|
348
|
+
if table["status"] == "not_calculated"
|
|
349
|
+
allowed_reason = DecisionTable::NOT_CALCULATED_REASONS.include?(table["reason"])
|
|
350
|
+
fail_with("not_calculated decision table requires a supported reason") unless allowed_reason
|
|
351
|
+
expected_status = table["reason"] == "unsupported_decision" ? "unsupported" : "not_calculated"
|
|
352
|
+
unless table["coverage_status"] == expected_status
|
|
353
|
+
fail_with("not_calculated decision table coverage status mismatch")
|
|
354
|
+
end
|
|
355
|
+
fail_with("not_calculated decision table percentage must be null") unless table["percentage"].nil?
|
|
356
|
+
unless table["reachability_analyzed"] == false
|
|
357
|
+
fail_with("not_calculated decision table reachability must be false")
|
|
358
|
+
end
|
|
359
|
+
return
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
fail_with("calculated decision table reason must be null") unless table["reason"].nil?
|
|
363
|
+
reachability_analyzed = table["reachability_analyzed"]
|
|
364
|
+
unless [true, false].include?(reachability_analyzed)
|
|
365
|
+
fail_with("decision_table reachability_analyzed must be boolean")
|
|
366
|
+
end
|
|
367
|
+
required = table["required_rules"]
|
|
368
|
+
covered = table["covered_rules"]
|
|
369
|
+
generated = table["generated_rules"]
|
|
370
|
+
expected_status = DecisionTable.coverage_status(covered, required, generated)
|
|
371
|
+
fail_with("decision_table coverage status mismatch") unless table["coverage_status"] == expected_status
|
|
372
|
+
expected_percentage = DecisionTable.percentage(covered, required)
|
|
373
|
+
fail_with("decision_table percentage mismatch") unless table["percentage"] == expected_percentage
|
|
374
|
+
return unless table["reachability_analyzed"] == false
|
|
375
|
+
|
|
376
|
+
fail_with("un-analyzed reachability cannot exclude rules") if rules.any? do |rule|
|
|
377
|
+
rule["coverage"] == "excluded"
|
|
378
|
+
end
|
|
379
|
+
fail_with("un-analyzed reachability contains impossible rule") if rules.any? do |rule|
|
|
380
|
+
rule["reachability"] == "statically_impossible"
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
def validate_decision_table_evidence(rule, _decision)
|
|
385
|
+
rule["vector_ids"].each do |vector_id|
|
|
386
|
+
vector = @vectors_by_id.fetch(vector_id)
|
|
387
|
+
matches = vector["outcome"] == rule["outcome"] && rule["conditions"].each_with_index.all? do |required, index|
|
|
388
|
+
required == DecisionTable::DONT_CARE || vector["values"][index] == (required == DecisionTable::TRUE_VALUE)
|
|
389
|
+
end
|
|
390
|
+
fail_with("decision table rule evidence does not match its conditions and outcome") unless matches
|
|
391
|
+
end
|
|
392
|
+
expected_tests = rule["vector_ids"].flat_map { |id| @vectors_by_id.fetch(id)["test_ids"] }.uniq.sort
|
|
393
|
+
fail_with("decision table rule tests do not match its evidence") unless rule["tests"].sort == expected_tests
|
|
394
|
+
expected_unattributed = rule["vector_ids"].sum { |id| @vectors_by_id.fetch(id).fetch("unattributed_count", 0) }
|
|
395
|
+
unless rule["unattributed_count"] == expected_unattributed
|
|
396
|
+
fail_with("decision table rule unattributed count does not match its evidence")
|
|
397
|
+
end
|
|
398
|
+
if rule["coverage"] == "covered" && rule["reachability"] != "observed"
|
|
399
|
+
fail_with("covered decision table rule requires observed reachability")
|
|
400
|
+
end
|
|
401
|
+
if rule["coverage"] == "excluded" && rule["reachability"] != "statically_impossible"
|
|
402
|
+
fail_with("excluded decision table rule requires static impossibility")
|
|
403
|
+
end
|
|
404
|
+
if rule["coverage"] == "missing" && rule["reachability"] != "unknown"
|
|
405
|
+
fail_with("missing decision table rule requires unknown reachability")
|
|
406
|
+
end
|
|
407
|
+
return unless %w[missing excluded].include?(rule["coverage"])
|
|
408
|
+
|
|
409
|
+
fail_with("noncovered decision table rule must not contain evidence") unless rule["vector_ids"].empty? &&
|
|
410
|
+
rule["tests"].empty? &&
|
|
411
|
+
rule["unattributed_count"].zero?
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
def decision_kind(decision)
|
|
415
|
+
kind = decision && decision["kind"]
|
|
416
|
+
kind.nil? || kind.empty? ? "boolean" : kind
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
def nonboolean_kind?(kind)
|
|
420
|
+
NONBOOLEAN_KINDS.include?(kind)
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
def validate_alternatives(alternatives)
|
|
424
|
+
fail_with("alternatives must be an array") unless alternatives.is_a?(Array)
|
|
425
|
+
ids = unique_ids(alternatives, "id", "alternative")
|
|
426
|
+
alternatives.each do |alternative|
|
|
427
|
+
fail_with("alternative index must be an integer") unless alternative["index"].is_a?(Integer)
|
|
428
|
+
fail_with("alternative expression must be a string") unless alternative["expression"].is_a?(String)
|
|
429
|
+
%w[line column byte_start byte_length].each do |field|
|
|
430
|
+
validate_integer_field(alternative, field, nullable: true)
|
|
431
|
+
end
|
|
432
|
+
end
|
|
433
|
+
indexes = alternatives.map { |alternative| alternative["index"] }
|
|
434
|
+
fail_with("alternative indexes must be consecutive") unless indexes.sort == (0...alternatives.length).to_a
|
|
435
|
+
ids
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
def validate_nonboolean_analysis(analysis_decision, inventory_decision)
|
|
439
|
+
conditions = analysis_decision["conditions"]
|
|
440
|
+
fail_with("nonboolean conditions must be empty") unless conditions.nil? || conditions == []
|
|
441
|
+
coverage = analysis_decision["coverage"]
|
|
442
|
+
if coverage.nil?
|
|
443
|
+
return unless strict_flow_decision?(inventory_decision)
|
|
444
|
+
|
|
445
|
+
fail_with("complete flow analysis requires coverage")
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
fail_with("flow coverage must be an object") unless hash_with_string_keys?(coverage)
|
|
449
|
+
fail_with("alternative coverage must be an object") unless hash_with_string_keys?(coverage["alternative"])
|
|
450
|
+
validate_alternative_coverage(coverage["alternative"], inventory_decision)
|
|
451
|
+
mcdc = coverage["mcdc"]
|
|
452
|
+
if mcdc.nil?
|
|
453
|
+
fail_with("flow MC/DC status must be explicit") if strict_flow_decision?(inventory_decision)
|
|
454
|
+
return
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
allowed = if inventory_decision["support_status"].to_s.upcase == "UNSUPPORTED"
|
|
458
|
+
%w[not_applicable unsupported]
|
|
459
|
+
else
|
|
460
|
+
["not_applicable"]
|
|
461
|
+
end
|
|
462
|
+
fail_with("nonboolean MC/DC must be not_applicable") unless hash_with_string_keys?(mcdc) &&
|
|
463
|
+
allowed.include?(mcdc["status"])
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
def validate_alternative_coverage(coverage, decision)
|
|
467
|
+
allowed_statuses = %w[covered partial unexecuted]
|
|
468
|
+
allowed_statuses << "unsupported" if decision["support_status"].to_s.upcase == "UNSUPPORTED"
|
|
469
|
+
fail_with("invalid alternative coverage status") unless allowed_statuses.include?(coverage["status"])
|
|
470
|
+
%w[covered_alternatives required_alternatives].each do |field|
|
|
471
|
+
validate_integer_field(coverage, field)
|
|
472
|
+
end
|
|
473
|
+
expected = decision.fetch("alternatives")
|
|
474
|
+
expected_for_coverage = coverage["status"] == "unsupported" ? [] : expected
|
|
475
|
+
if strict_flow_decision?(decision)
|
|
476
|
+
%w[covered_alternatives required_alternatives].each do |field|
|
|
477
|
+
fail_with("#{field} must be an integer") unless coverage[field].is_a?(Integer)
|
|
478
|
+
end
|
|
479
|
+
end
|
|
480
|
+
unless coverage["required_alternatives"] == expected_for_coverage.length
|
|
481
|
+
fail_with("alternative coverage count mismatch")
|
|
482
|
+
end
|
|
483
|
+
if strict_flow_decision?(decision)
|
|
484
|
+
fail_with("alternative coverage count must be nonnegative") unless coverage["covered_alternatives"] >= 0 &&
|
|
485
|
+
coverage["required_alternatives"] >= 0
|
|
486
|
+
fail_with("covered alternatives exceed required alternatives") unless coverage["covered_alternatives"] <=
|
|
487
|
+
coverage["required_alternatives"]
|
|
488
|
+
end
|
|
489
|
+
alternatives = coverage["alternatives"]
|
|
490
|
+
fail_with("alternative coverage alternatives must be an array") unless alternatives.is_a?(Array)
|
|
491
|
+
seen = {}
|
|
492
|
+
alternatives.each do |row|
|
|
493
|
+
fail_with("invalid alternative coverage row") unless hash_with_string_keys?(row)
|
|
494
|
+
id = row["alternative_id"]
|
|
495
|
+
inventory = expected_for_coverage.find { |alternative| alternative["id"] == id }
|
|
496
|
+
fail_with("unknown alternative coverage id") unless inventory && !seen.key?(id)
|
|
497
|
+
seen[id] = true
|
|
498
|
+
unless row["index"].is_a?(Integer) && row["index"] == inventory["index"]
|
|
499
|
+
fail_with("alternative coverage index mismatch")
|
|
500
|
+
end
|
|
501
|
+
fail_with("alternative coverage expression must be a string") unless row["expression"].is_a?(String)
|
|
502
|
+
%w[selected not_selected skipped].each do |state|
|
|
503
|
+
if strict_flow_decision?(decision)
|
|
504
|
+
validate_alternative_evidence(row[state], decision, inventory["index"], state)
|
|
505
|
+
else
|
|
506
|
+
validate_alternative_evidence(row[state])
|
|
507
|
+
end
|
|
508
|
+
end
|
|
509
|
+
end
|
|
510
|
+
fail_with("alternative coverage missing rows") unless seen.keys.sort == expected_for_coverage.map { |alternative|
|
|
511
|
+
alternative["id"]
|
|
512
|
+
}.sort
|
|
513
|
+
missing = coverage["missing_alternatives"]
|
|
514
|
+
fail_with("missing alternatives must be an array of strings") unless strings?(missing)
|
|
515
|
+
if strict_flow_decision?(decision)
|
|
516
|
+
expected_missing = alternatives.filter_map do |row|
|
|
517
|
+
row["alternative_id"] unless row.dig("selected", "observed")
|
|
518
|
+
end
|
|
519
|
+
fail_with("missing alternatives do not match selected evidence") unless missing.sort == expected_missing.sort
|
|
520
|
+
covered = alternatives.count { |row| row.dig("selected", "observed") }
|
|
521
|
+
unless coverage["covered_alternatives"] == covered
|
|
522
|
+
fail_with("covered alternatives do not match selected evidence")
|
|
523
|
+
end
|
|
524
|
+
expected_status = if covered.zero?
|
|
525
|
+
"unexecuted"
|
|
526
|
+
elsif covered == expected.length
|
|
527
|
+
"covered"
|
|
528
|
+
else
|
|
529
|
+
"partial"
|
|
530
|
+
end
|
|
531
|
+
fail_with("alternative coverage status does not match evidence") unless coverage["status"] == expected_status
|
|
532
|
+
end
|
|
533
|
+
fail_with("unknown missing alternative") unless (missing - expected.map do |alternative|
|
|
534
|
+
alternative["id"]
|
|
535
|
+
end).empty?
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
def validate_alternative_evidence(evidence, decision = nil, index = nil, state = nil)
|
|
539
|
+
fail_with("alternative evidence must be an object") unless hash_with_string_keys?(evidence)
|
|
540
|
+
fail_with("alternative evidence observed must be boolean") unless [true, false].include?(evidence["observed"])
|
|
541
|
+
fail_with("alternative evidence vector_ids must be strings") unless strings?(evidence["vector_ids"])
|
|
542
|
+
unless evidence["vector_ids"].uniq.length == evidence["vector_ids"].length
|
|
543
|
+
fail_with("duplicate alternative evidence vector")
|
|
544
|
+
end
|
|
545
|
+
fail_with("unknown alternative evidence vector") unless evidence["vector_ids"].all? do |id|
|
|
546
|
+
@vectors_by_id.key?(id)
|
|
547
|
+
end
|
|
548
|
+
fail_with("alternative evidence test_ids must be strings") unless strings?(evidence["test_ids"])
|
|
549
|
+
unless evidence["test_ids"].uniq.length == evidence["test_ids"].length
|
|
550
|
+
fail_with("duplicate alternative evidence test")
|
|
551
|
+
end
|
|
552
|
+
fail_with("unknown alternative evidence test") unless evidence["test_ids"].all? do |id|
|
|
553
|
+
@test_id_set.include?(id)
|
|
554
|
+
end
|
|
555
|
+
validate_integer_field(evidence, "unattributed_count")
|
|
556
|
+
return unless decision
|
|
557
|
+
|
|
558
|
+
vectors = evidence["vector_ids"].map { |id| @vectors_by_id.fetch(id) }
|
|
559
|
+
fail_with("alternative evidence references another decision") unless vectors.all? do |vector|
|
|
560
|
+
vector["decision_id"] == decision["id"]
|
|
561
|
+
end
|
|
562
|
+
expected_value = lambda do |vector|
|
|
563
|
+
value = vector["values"][index]
|
|
564
|
+
if state == "selected"
|
|
565
|
+
value == true
|
|
566
|
+
else
|
|
567
|
+
state == "not_selected" ? value == false : value.nil?
|
|
568
|
+
end
|
|
569
|
+
end
|
|
570
|
+
fail_with("alternative evidence state mismatch") unless vectors.all?(&expected_value)
|
|
571
|
+
decision_vectors = @vectors_by_decision.fetch(decision["id"], [])
|
|
572
|
+
expected_vectors = decision_vectors.select(&expected_value)
|
|
573
|
+
expected_vector_ids = expected_vectors.map { |vector| vector["id"] }.sort
|
|
574
|
+
fail_with("alternative evidence is incomplete") unless evidence["vector_ids"].sort == expected_vector_ids
|
|
575
|
+
expected_tests = vectors.flat_map { |vector| vector["test_ids"] }.uniq.sort
|
|
576
|
+
fail_with("alternative evidence test owners mismatch") unless evidence["test_ids"].sort == expected_tests
|
|
577
|
+
expected_unattributed = vectors.sum { |vector| vector.fetch("unattributed_count", 0).to_i }
|
|
578
|
+
fail_with("alternative evidence unattributed count mismatch") unless evidence.fetch("unattributed_count",
|
|
579
|
+
0) == expected_unattributed
|
|
580
|
+
fail_with("alternative evidence observed mismatch") unless evidence["observed"] == !vectors.empty?
|
|
581
|
+
end
|
|
582
|
+
|
|
583
|
+
def validate_flow_vector(vector, decision)
|
|
584
|
+
values = vector["values"]
|
|
585
|
+
valid = if decision_kind(decision) == "implicit"
|
|
586
|
+
vector["outcome"] == true && values.length == 2 && values.all? do |value|
|
|
587
|
+
[true, false].include?(value)
|
|
588
|
+
end && values.count(true) == 1
|
|
589
|
+
else
|
|
590
|
+
observations = values.each_with_index.filter_map { |value, index| [index, value] unless value.nil? }
|
|
591
|
+
vector["outcome"] == true &&
|
|
592
|
+
observations.any? &&
|
|
593
|
+
observations.each_with_index.all? do |(index, value), position|
|
|
594
|
+
index == position && value == (position == observations.length - 1)
|
|
595
|
+
end
|
|
596
|
+
end
|
|
597
|
+
fail_with("invalid flow vector trace") unless valid
|
|
598
|
+
end
|
|
599
|
+
|
|
600
|
+
def strict_flow_decision?(decision)
|
|
601
|
+
STRICT_FLOW_SCHEMAS.include?(@schema_version) &&
|
|
602
|
+
nonboolean_kind?(decision_kind(decision)) &&
|
|
603
|
+
decision["support_status"].to_s.upcase != "UNSUPPORTED"
|
|
604
|
+
end
|
|
605
|
+
|
|
191
606
|
def validate_completeness(completeness)
|
|
192
607
|
fail_with("completeness must be an object") unless hash_with_string_keys?(completeness)
|
|
193
608
|
COMPLETENESS_FIELDS.each do |field|
|
|
@@ -217,7 +632,7 @@ module Branchproof
|
|
|
217
632
|
|
|
218
633
|
valid = hash_with_string_keys?(phases) && phases.all? do |test_id, values|
|
|
219
634
|
valid_phases = %w[setup body teardown suite unattributed]
|
|
220
|
-
@
|
|
635
|
+
@test_id_set.include?(test_id) && strings?(values) && values.all? { |phase| valid_phases.include?(phase) }
|
|
221
636
|
end
|
|
222
637
|
fail_with("invalid phases_by_test") unless valid
|
|
223
638
|
end
|
|
@@ -277,9 +692,17 @@ module Branchproof
|
|
|
277
692
|
analysis = @document["analysis"]
|
|
278
693
|
return unless analysis && analysis.dig("completeness", "analysis") == true
|
|
279
694
|
|
|
280
|
-
|
|
695
|
+
analysis_by_id = analysis["decisions"].to_h { |decision| [decision["decision_id"], decision] }
|
|
281
696
|
@supported_ids.each do |id|
|
|
282
|
-
|
|
697
|
+
inventory_decision = @decisions_by_id.fetch(id)
|
|
698
|
+
analysis_decision = analysis_by_id[id]
|
|
699
|
+
if strict_flow_decision?(inventory_decision)
|
|
700
|
+
fail_with("complete analysis is missing flow decision") unless analysis_decision
|
|
701
|
+
validate_nonboolean_analysis(analysis_decision, inventory_decision)
|
|
702
|
+
end
|
|
703
|
+
actual = Array(analysis_decision && analysis_decision["condition_results"]).map do |result|
|
|
704
|
+
result["condition_id"]
|
|
705
|
+
end.sort
|
|
283
706
|
fail_with("complete analysis is missing condition results") unless actual == @conditions_by_decision[id].sort
|
|
284
707
|
end
|
|
285
708
|
end
|
|
@@ -319,7 +742,7 @@ module Branchproof
|
|
|
319
742
|
return unless locations
|
|
320
743
|
|
|
321
744
|
valid = hash_with_string_keys?(locations) && locations.all? do |test_id, location|
|
|
322
|
-
@
|
|
745
|
+
@test_id_set.include?(test_id) && hash_with_string_keys?(location) &&
|
|
323
746
|
(!location.key?("relative_path") || location["relative_path"].nil? ||
|
|
324
747
|
location["relative_path"].is_a?(String)) &&
|
|
325
748
|
(!location.key?("line") || location["line"].nil? || location["line"].is_a?(Integer))
|
|
@@ -364,3 +787,5 @@ module Branchproof
|
|
|
364
787
|
end
|
|
365
788
|
|
|
366
789
|
# rubocop:enable Metrics/ClassLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
790
|
+
|
|
791
|
+
# rubocop:enable Metrics/BlockLength
|