branchproof 0.3.0 → 0.5.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 +19 -0
- data/README.md +98 -9
- data/doc/Branchproof/Comparison.md +26 -0
- data/doc/Branchproof/ComparisonReport.md +19 -0
- data/doc/Branchproof/CoverageIndex.md +19 -0
- data/doc/Branchproof/FocusedReport.md +16 -0
- data/doc/Branchproof/Report.md +11 -1
- data/doc/Branchproof/SavedReport.md +32 -0
- data/doc/Branchproof.md +6 -1
- data/doc/CHANGELOG.md +19 -0
- data/doc/README.md +98 -9
- data/exe/branchproof +6 -0
- data/lib/branchproof/cli.rb +152 -7
- data/lib/branchproof/comparison.rb +363 -0
- data/lib/branchproof/comparison_report.rb +119 -0
- data/lib/branchproof/coverage_index.rb +187 -0
- data/lib/branchproof/focused_report.rb +205 -0
- data/lib/branchproof/loader.rb +9 -1
- data/lib/branchproof/report.rb +71 -6
- data/lib/branchproof/saved_report.rb +366 -0
- data/lib/branchproof/source.rb +1 -0
- data/lib/branchproof/version.rb +1 -1
- data/lib/branchproof.rb +5 -0
- data/llms.txt +6 -1
- data/sig/branchproof.rbs +31 -1
- metadata +13 -1
data/lib/branchproof/report.rb
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
+
require "pathname"
|
|
4
5
|
|
|
5
6
|
module Branchproof
|
|
6
7
|
# Renders versioned terminal and JSON analysis reports.
|
|
7
8
|
class Report
|
|
8
|
-
SCHEMA_VERSION = "1.
|
|
9
|
+
SCHEMA_VERSION = "1.1"
|
|
9
10
|
CRITERION_VERSION = "masking_occurrence_v1"
|
|
10
11
|
|
|
11
|
-
def initialize(inventory:, evidence:, analysis:, minima:, baseline:, diagnostics:, level: 3, missing_only: false
|
|
12
|
+
def initialize(inventory:, evidence:, analysis:, minima:, baseline:, diagnostics:, level: 3, missing_only: false,
|
|
13
|
+
view: :decisions, run_metadata: {}, saved_document: nil)
|
|
12
14
|
raise ArgumentError, "level must be 1, 2, or 3" unless [1, 2, 3].include?(level.to_i)
|
|
15
|
+
raise ArgumentError, "view must be :decisions, :conditions, or :tests" unless %i[decisions conditions
|
|
16
|
+
tests].include?(view.to_sym)
|
|
13
17
|
|
|
14
18
|
@inventory = inventory || {}
|
|
15
19
|
@evidence = evidence || {}
|
|
@@ -18,7 +22,20 @@ module Branchproof
|
|
|
18
22
|
@baseline = baseline || {}
|
|
19
23
|
@diagnostics = Array(diagnostics)
|
|
20
24
|
@level = level.to_i
|
|
21
|
-
@missing_only =
|
|
25
|
+
@missing_only = missing_only ? true : false
|
|
26
|
+
@view = view.to_sym
|
|
27
|
+
@run_metadata = run_metadata || {}
|
|
28
|
+
@saved_document = saved_document
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.from_document(document:, level: nil, view: :decisions, missing_only: false)
|
|
32
|
+
data = document || {}
|
|
33
|
+
new(inventory: data[:source_inventory] || data["source_inventory"] || data[:inventory] || data["inventory"],
|
|
34
|
+
evidence: data[:observations] || data["observations"] || data[:evidence] || data["evidence"],
|
|
35
|
+
analysis: data[:analysis] || data["analysis"], minima: data[:minima] || data["minima"],
|
|
36
|
+
baseline: data[:baseline] || data["baseline"], diagnostics: data[:diagnostics] || data["diagnostics"],
|
|
37
|
+
level: level || (data[:analysis] || data["analysis"] ? 3 : 1), view: view, missing_only: missing_only,
|
|
38
|
+
run_metadata: data[:run_metadata] || data["run_metadata"], saved_document: data)
|
|
22
39
|
end
|
|
23
40
|
|
|
24
41
|
def write(io:, format:)
|
|
@@ -29,6 +46,30 @@ module Branchproof
|
|
|
29
46
|
nil
|
|
30
47
|
end
|
|
31
48
|
|
|
49
|
+
# Shares the existing missing-case wording with focused terminal views.
|
|
50
|
+
def condition_explanation(decision_id:, condition_id:)
|
|
51
|
+
decision = inventory_decisions.find { |item| value(item, :id).to_s == decision_id.to_s }
|
|
52
|
+
return "" unless decision
|
|
53
|
+
|
|
54
|
+
condition = Array(value(decision, :conditions)).find { |item| value(item, :id).to_s == condition_id.to_s }
|
|
55
|
+
return "" unless condition
|
|
56
|
+
|
|
57
|
+
@terminal_ids ||= terminal_ids
|
|
58
|
+
condition_detail(decision, condition, condition_result(decision, condition))
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Formats source context consistently in live and saved terminal views.
|
|
62
|
+
def diagnostic_message(diagnostic)
|
|
63
|
+
message = value(diagnostic, :message) || value(diagnostic, :code)
|
|
64
|
+
source_id = value(diagnostic, :source_id)
|
|
65
|
+
return message unless source_id
|
|
66
|
+
|
|
67
|
+
source = source_for(diagnostic)
|
|
68
|
+
path = value(source, :relative_path) || source_id
|
|
69
|
+
prefix = %w[not_instrumented unsupported_source].include?(value(diagnostic, :code).to_s) ? "Skipped " : ""
|
|
70
|
+
"#{prefix}#{path}: #{message}"
|
|
71
|
+
end
|
|
72
|
+
|
|
32
73
|
def exit_code
|
|
33
74
|
return 2 unless usage_valid?
|
|
34
75
|
|
|
@@ -36,6 +77,7 @@ module Branchproof
|
|
|
36
77
|
return 2 if %w[ERROR INCOMPLETE].include?(status)
|
|
37
78
|
return 1 if status == "FAILED"
|
|
38
79
|
return 2 unless status == "PASSED" && value(@baseline, :finalized) == true
|
|
80
|
+
return 2 if @saved_document && (value(@saved_document, :completeness) || {}).values.include?(false)
|
|
39
81
|
return 2 unless metrics[:eligible_conditions].positive?
|
|
40
82
|
return 2 unless valid_for_requested_level?
|
|
41
83
|
|
|
@@ -45,16 +87,24 @@ module Branchproof
|
|
|
45
87
|
private
|
|
46
88
|
|
|
47
89
|
def json_document
|
|
90
|
+
return normalize(@saved_document) if @saved_document
|
|
91
|
+
|
|
48
92
|
normalize(schema_version: SCHEMA_VERSION,
|
|
49
93
|
tool_version: (defined?(Branchproof::VERSION) ? Branchproof::VERSION : "unknown"),
|
|
50
94
|
criterion_version: CRITERION_VERSION, runtime: RUBY_DESCRIPTION,
|
|
51
95
|
run_ids: Array(value(@evidence, :run_ids)),
|
|
52
96
|
source_inventory: @inventory, baseline: @baseline, observations: @evidence,
|
|
53
97
|
analysis: @level == 1 ? nil : @analysis, minima: @minima, metrics: metrics,
|
|
54
|
-
diagnostics: @diagnostics, completeness: completeness
|
|
98
|
+
diagnostics: @diagnostics, completeness: completeness,
|
|
99
|
+
run_metadata: @run_metadata)
|
|
55
100
|
end
|
|
56
101
|
|
|
57
102
|
def terminal_document
|
|
103
|
+
unless @view == :decisions
|
|
104
|
+
return FocusedReport.new(document: json_document, view: @view, level: @level,
|
|
105
|
+
missing_only: @missing_only).render
|
|
106
|
+
end
|
|
107
|
+
|
|
58
108
|
@terminal_ids = terminal_ids
|
|
59
109
|
lines = ["Branchproof #{defined?(Branchproof::VERSION) ? Branchproof::VERSION : "unknown"}",
|
|
60
110
|
"Tests: #{baseline_status} (#{baseline_test_counts})",
|
|
@@ -72,7 +122,7 @@ module Branchproof
|
|
|
72
122
|
render_minima(lines) unless @missing_only
|
|
73
123
|
unless @diagnostics.empty?
|
|
74
124
|
lines << "Diagnostics:"
|
|
75
|
-
@diagnostics.each { |diagnostic| lines << " - #{
|
|
125
|
+
@diagnostics.each { |diagnostic| lines << " - #{diagnostic_message(diagnostic)}" }
|
|
76
126
|
end
|
|
77
127
|
lines.join("\n") << "\n"
|
|
78
128
|
end
|
|
@@ -224,17 +274,32 @@ module Branchproof
|
|
|
224
274
|
end
|
|
225
275
|
|
|
226
276
|
def test_location(test)
|
|
277
|
+
saved_location = value(value(@run_metadata, :test_locations), value(test, :id))
|
|
278
|
+
return [relative_test_path(value(saved_location, :relative_path)), value(saved_location, :line)] if saved_location
|
|
279
|
+
|
|
227
280
|
location = value(test, :source)
|
|
228
281
|
source_line = nil
|
|
229
282
|
if location.respond_to?(:key?)
|
|
230
283
|
source_line = value(location, :line)
|
|
231
|
-
location = value(location, :
|
|
284
|
+
location = value(location, :relative_path) || value(location, :path)
|
|
232
285
|
end
|
|
233
286
|
location ||= value(test, :source_path)
|
|
287
|
+
location = relative_test_path(location)
|
|
234
288
|
line = value(test, :line) || value(test, :source_line) || source_line
|
|
235
289
|
[location, line]
|
|
236
290
|
end
|
|
237
291
|
|
|
292
|
+
def relative_test_path(path)
|
|
293
|
+
return path unless path && Pathname.new(path.to_s).absolute?
|
|
294
|
+
|
|
295
|
+
root = value(@inventory, :root) || value(@run_metadata, :project_root)
|
|
296
|
+
return nil unless root && Pathname.new(root.to_s).absolute?
|
|
297
|
+
|
|
298
|
+
Pathname.new(path.to_s).relative_path_from(Pathname.new(root.to_s)).to_s
|
|
299
|
+
rescue ArgumentError
|
|
300
|
+
nil
|
|
301
|
+
end
|
|
302
|
+
|
|
238
303
|
def minimum_member_label(objective, id)
|
|
239
304
|
objective.to_s == "tests" ? test_label(id) : short_id(id)
|
|
240
305
|
end
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# rubocop:disable Metrics/ClassLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
4
|
+
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
module Branchproof
|
|
8
|
+
# Reads and validates a persisted JSON report without loading the project.
|
|
9
|
+
class SavedReport
|
|
10
|
+
SUPPORTED_SCHEMAS = %w[1.0 1.1].freeze
|
|
11
|
+
CRITERION_VERSION = "masking_occurrence_v1"
|
|
12
|
+
REQUIRED_FIELDS = %w[schema_version tool_version criterion_version runtime run_ids source_inventory baseline
|
|
13
|
+
observations analysis minima metrics diagnostics completeness].freeze
|
|
14
|
+
COMPLETENESS_FIELDS = %w[observation attribution analysis].freeze
|
|
15
|
+
|
|
16
|
+
class << self
|
|
17
|
+
def read(path)
|
|
18
|
+
raise ArgumentError, "report path must be a string" unless path.is_a?(String)
|
|
19
|
+
|
|
20
|
+
json = File.binread(path)
|
|
21
|
+
document = JSON.parse(json)
|
|
22
|
+
new(document).validate!
|
|
23
|
+
rescue IOError, SystemCallError => e
|
|
24
|
+
raise ArgumentError, "cannot read report: #{e.message}"
|
|
25
|
+
rescue JSON::ParserError
|
|
26
|
+
raise ArgumentError, "invalid report JSON"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def initialize(document)
|
|
31
|
+
@document = document
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def validate!
|
|
35
|
+
fail_with("document must be an object") unless hash_with_string_keys?(@document)
|
|
36
|
+
schema = @document["schema_version"]
|
|
37
|
+
fail_with("unsupported schema version") unless SUPPORTED_SCHEMAS.include?(schema)
|
|
38
|
+
missing = REQUIRED_FIELDS.reject { |field| @document.key?(field) }
|
|
39
|
+
fail_with("missing field: #{missing.first}") unless missing.empty?
|
|
40
|
+
fail_with("criterion version mismatch") unless @document["criterion_version"] == CRITERION_VERSION
|
|
41
|
+
fail_with("run_ids must be an array of strings") unless strings?(@document["run_ids"])
|
|
42
|
+
|
|
43
|
+
validate_inventory(@document["source_inventory"])
|
|
44
|
+
validate_observations(@document["observations"])
|
|
45
|
+
validate_baseline(@document["baseline"])
|
|
46
|
+
validate_analysis(@document["analysis"])
|
|
47
|
+
validate_completeness(@document["completeness"])
|
|
48
|
+
validate_completeness(@document.dig("observations", "completeness"))
|
|
49
|
+
validate_completeness(@document.dig("analysis", "completeness")) if @document["analysis"]
|
|
50
|
+
validate_optional_sections
|
|
51
|
+
validate_analysis_coverage
|
|
52
|
+
@document
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def validate_inventory(inventory)
|
|
58
|
+
fail_with("source_inventory must be an object") unless hash_with_string_keys?(inventory)
|
|
59
|
+
validate_string_field(inventory, "root", nullable: true)
|
|
60
|
+
sources = inventory["source_units"]
|
|
61
|
+
decisions = inventory["decisions"]
|
|
62
|
+
fail_with("source_units must be an array") unless sources.is_a?(Array)
|
|
63
|
+
fail_with("decisions must be an array") unless decisions.is_a?(Array)
|
|
64
|
+
source_ids = unique_ids(sources, "source_id", "source")
|
|
65
|
+
decision_ids = unique_ids(decisions, "id", "decision")
|
|
66
|
+
decisions.each do |decision|
|
|
67
|
+
fail_with("invalid decision") unless hash_with_string_keys?(decision)
|
|
68
|
+
fail_with("unknown decision source") unless source_ids.include?(decision["source_id"])
|
|
69
|
+
validate_string_field(decision, "expression")
|
|
70
|
+
validate_integer_field(decision, "line")
|
|
71
|
+
conditions = decision["conditions"]
|
|
72
|
+
fail_with("conditions must be an array") unless conditions.is_a?(Array)
|
|
73
|
+
unique_ids(conditions, "id", "condition")
|
|
74
|
+
conditions.each do |condition|
|
|
75
|
+
fail_with("invalid condition") unless hash_with_string_keys?(condition) && condition["index"].is_a?(Integer)
|
|
76
|
+
validate_string_field(condition, "expression")
|
|
77
|
+
%w[line column byte_start byte_length].each do |field|
|
|
78
|
+
validate_integer_field(condition, field, nullable: true)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
fail_with("condition indexes must be consecutive") unless conditions.map do |condition|
|
|
82
|
+
condition["index"]
|
|
83
|
+
end.sort == (0...conditions.length).to_a
|
|
84
|
+
end
|
|
85
|
+
@source_ids = source_ids
|
|
86
|
+
@decision_ids = decision_ids
|
|
87
|
+
@condition_ids = decisions.flat_map do |decision|
|
|
88
|
+
Array(decision["conditions"]).map do |condition|
|
|
89
|
+
condition["id"]
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
fail_with("duplicate condition id") unless @condition_ids.uniq.length == @condition_ids.length
|
|
93
|
+
@conditions_by_decision = decisions.to_h do |decision|
|
|
94
|
+
[decision["id"], Array(decision["conditions"]).map { |condition| condition["id"] }]
|
|
95
|
+
end
|
|
96
|
+
sources.each do |source|
|
|
97
|
+
validate_string_field(source, "relative_path", nullable: true)
|
|
98
|
+
validate_string_field(source, "absolute_path", nullable: true)
|
|
99
|
+
digest = source["digest"] || source["content_digest"]
|
|
100
|
+
fail_with("source digest must be a nonempty string") unless digest.is_a?(String) && !digest.empty?
|
|
101
|
+
end
|
|
102
|
+
paths = sources.map { |source| source["relative_path"] }.compact
|
|
103
|
+
fail_with("duplicate source relative path") unless paths.uniq.length == paths.length
|
|
104
|
+
@condition_counts = decisions.to_h { |decision| [decision["id"], decision["conditions"].length] }
|
|
105
|
+
@supported_ids = decisions.filter_map do |decision|
|
|
106
|
+
decision["id"] unless decision["support_status"] == "UNSUPPORTED"
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def validate_observations(observations)
|
|
111
|
+
fail_with("observations must be an object") unless hash_with_string_keys?(observations)
|
|
112
|
+
tests = observations["tests"]
|
|
113
|
+
vectors = observations["vectors"]
|
|
114
|
+
fail_with("tests must be an array") unless tests.is_a?(Array)
|
|
115
|
+
fail_with("vectors must be an array") unless vectors.is_a?(Array)
|
|
116
|
+
@test_ids = unique_ids(tests, "id", "test")
|
|
117
|
+
tests.each { |test| validate_test(test) }
|
|
118
|
+
vector_ids = unique_ids(vectors, "id", "vector")
|
|
119
|
+
vectors.each do |vector|
|
|
120
|
+
fail_with("invalid vector") unless hash_with_string_keys?(vector)
|
|
121
|
+
fail_with("unknown vector decision") unless @decision_ids.include?(vector["decision_id"])
|
|
122
|
+
values = vector["values"]
|
|
123
|
+
valid_values = values.is_a?(Array) && values.all? do |value|
|
|
124
|
+
value.nil? || value == true || value == false
|
|
125
|
+
end
|
|
126
|
+
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
|
|
129
|
+
fail_with("vector outcome must be boolean") unless [true, false].include?(vector["outcome"])
|
|
130
|
+
fail_with("unknown vector test") unless strings?(vector["test_ids"]) && vector["test_ids"].all? do |id|
|
|
131
|
+
@test_ids.include?(id)
|
|
132
|
+
end
|
|
133
|
+
validate_phases(vector)
|
|
134
|
+
validate_integer_field(vector, "count")
|
|
135
|
+
validate_integer_field(vector, "unattributed_count")
|
|
136
|
+
end
|
|
137
|
+
@vector_ids = vector_ids
|
|
138
|
+
@vector_decision_by_id = vectors.to_h { |vector| [vector["id"], vector["decision_id"]] }
|
|
139
|
+
aborts = observations["abort_counts"]
|
|
140
|
+
return if aborts.nil? || aborts.is_a?(Integer) || (aborts.is_a?(Hash) && aborts.values.all?(Integer))
|
|
141
|
+
|
|
142
|
+
fail_with("invalid abort_counts")
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def validate_baseline(baseline)
|
|
146
|
+
fail_with("baseline must be an object") unless hash_with_string_keys?(baseline)
|
|
147
|
+
fail_with("baseline status must be a string") unless baseline["status"].is_a?(String)
|
|
148
|
+
fail_with("baseline finalized must be boolean") unless [true, false].include?(baseline["finalized"])
|
|
149
|
+
return unless baseline.key?("tests")
|
|
150
|
+
|
|
151
|
+
fail_with("baseline tests must be an array") unless baseline["tests"].is_a?(Array)
|
|
152
|
+
unique_ids(baseline["tests"], "id", "baseline test")
|
|
153
|
+
baseline["tests"].each { |test| validate_test(test) }
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def validate_analysis(analysis)
|
|
157
|
+
return if analysis.nil?
|
|
158
|
+
|
|
159
|
+
fail_with("analysis must be an object") unless hash_with_string_keys?(analysis)
|
|
160
|
+
%w[proven_count eligible_count].each { |field| validate_integer_field(analysis, field) }
|
|
161
|
+
decisions = analysis["decisions"]
|
|
162
|
+
fail_with("analysis decisions must be an array") unless decisions.is_a?(Array)
|
|
163
|
+
seen = {}
|
|
164
|
+
decisions.each do |decision|
|
|
165
|
+
fail_with("invalid analysis decision") unless hash_with_string_keys?(decision)
|
|
166
|
+
id = decision["decision_id"]
|
|
167
|
+
fail_with("unknown analysis decision") unless @decision_ids.include?(id) && !seen.key?(id)
|
|
168
|
+
seen[id] = true
|
|
169
|
+
results = decision["condition_results"]
|
|
170
|
+
fail_with("condition results must be an array") unless results.is_a?(Array)
|
|
171
|
+
seen_conditions = {}
|
|
172
|
+
results.each do |result|
|
|
173
|
+
condition_id = result["condition_id"] if hash_with_string_keys?(result)
|
|
174
|
+
fail_with("invalid condition result") unless @conditions_by_decision.fetch(id).include?(condition_id)
|
|
175
|
+
fail_with("duplicate condition result") if seen_conditions.key?(condition_id)
|
|
176
|
+
seen_conditions[condition_id] = true
|
|
177
|
+
validate_constraint(result["constraint_result"], id) if result["constraint_result"]
|
|
178
|
+
next unless result.key?("canonical_pair")
|
|
179
|
+
|
|
180
|
+
next if result["canonical_pair"].nil?
|
|
181
|
+
|
|
182
|
+
pair = result["canonical_pair"]
|
|
183
|
+
valid_pair = pair.is_a?(Array) && pair.length == 2 && pair.uniq.length == 2 && pair.all? do |id|
|
|
184
|
+
@vector_ids.include?(id) && @vector_decision_by_id[id] == decision["decision_id"]
|
|
185
|
+
end
|
|
186
|
+
fail_with("canonical pair must reference vectors") unless valid_pair
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def validate_completeness(completeness)
|
|
192
|
+
fail_with("completeness must be an object") unless hash_with_string_keys?(completeness)
|
|
193
|
+
COMPLETENESS_FIELDS.each do |field|
|
|
194
|
+
valid = completeness.key?(field) && [true, false].include?(completeness[field])
|
|
195
|
+
fail_with("completeness #{field} must be boolean") unless valid
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def validate_optional_sections
|
|
200
|
+
fail_with("minima must be an array") unless @document["minima"].is_a?(Array)
|
|
201
|
+
fail_with("metrics must be an object") unless hash_with_string_keys?(@document["metrics"])
|
|
202
|
+
fail_with("diagnostics must be an array") unless @document["diagnostics"].is_a?(Array)
|
|
203
|
+
@document["diagnostics"].each do |diagnostic|
|
|
204
|
+
fail_with("invalid diagnostic") unless hash_with_string_keys?(diagnostic)
|
|
205
|
+
end
|
|
206
|
+
@document["minima"].each { |minimum| validate_minimum(minimum) }
|
|
207
|
+
if @document.key?("run_metadata") && !hash_with_string_keys?(@document["run_metadata"])
|
|
208
|
+
fail_with("run_metadata must be an object")
|
|
209
|
+
end
|
|
210
|
+
validate_metadata(@document["run_metadata"]) if @document["run_metadata"]
|
|
211
|
+
fail_with("document contains non-string object keys") unless all_hashes_have_string_keys?(@document)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def validate_phases(vector)
|
|
215
|
+
phases = vector["phases_by_test"]
|
|
216
|
+
return if phases.nil?
|
|
217
|
+
|
|
218
|
+
valid = hash_with_string_keys?(phases) && phases.all? do |test_id, values|
|
|
219
|
+
valid_phases = %w[setup body teardown suite unattributed]
|
|
220
|
+
@test_ids.include?(test_id) && strings?(values) && values.all? { |phase| valid_phases.include?(phase) }
|
|
221
|
+
end
|
|
222
|
+
fail_with("invalid phases_by_test") unless valid
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def validate_test(test)
|
|
226
|
+
%w[name class_name method_name source_path].each { |field| validate_string_field(test, field, nullable: true) }
|
|
227
|
+
validate_integer_field(test, "line", nullable: true)
|
|
228
|
+
phase_counts = test["phase_counts"]
|
|
229
|
+
if phase_counts
|
|
230
|
+
valid = hash_with_string_keys?(phase_counts) && phase_counts.values.all?(Integer)
|
|
231
|
+
fail_with("invalid test phase_counts") unless valid
|
|
232
|
+
end
|
|
233
|
+
source = test["source"]
|
|
234
|
+
return if source.nil?
|
|
235
|
+
|
|
236
|
+
fail_with("test source must be an object") unless hash_with_string_keys?(source)
|
|
237
|
+
validate_string_field(source, "path", nullable: true)
|
|
238
|
+
validate_string_field(source, "relative_path", nullable: true)
|
|
239
|
+
validate_integer_field(source, "line", nullable: true)
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Keep each record's shape and reference checks together.
|
|
243
|
+
def validate_minimum(minimum)
|
|
244
|
+
fail_with("invalid minimum") unless hash_with_string_keys?(minimum)
|
|
245
|
+
%w[scope_decision_ids selected_ids].each do |field|
|
|
246
|
+
fail_with("minimum #{field} must be an array of strings") if minimum.key?(field) && !strings?(minimum[field])
|
|
247
|
+
end
|
|
248
|
+
validate_string_field(minimum, "objective", nullable: true)
|
|
249
|
+
validate_string_field(minimum, "status", nullable: true)
|
|
250
|
+
scope = minimum["scope_decision_ids"] || []
|
|
251
|
+
fail_with("unknown minimum decision") unless (scope - @decision_ids).empty?
|
|
252
|
+
ids = minimum["objective"] == "tests" ? @test_ids : @vector_ids
|
|
253
|
+
fail_with("unknown minimum member") unless ((minimum["selected_ids"] || []) - ids).empty?
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def validate_constraint(constraint, decision_id)
|
|
257
|
+
fail_with("constraint_result must be an object") unless hash_with_string_keys?(constraint)
|
|
258
|
+
%w[status feasibility_statement].each { |field| validate_string_field(constraint, field, nullable: true) }
|
|
259
|
+
%w[constraints candidate_vectors].each do |field|
|
|
260
|
+
fail_with("#{field} must be an array") if constraint.key?(field) && !constraint[field].is_a?(Array)
|
|
261
|
+
end
|
|
262
|
+
Array(constraint["candidate_vectors"]).each do |candidate|
|
|
263
|
+
fail_with("invalid candidate vector") unless hash_with_string_keys?(candidate)
|
|
264
|
+
values = candidate["values"]
|
|
265
|
+
unless values.is_a?(Array) && values.length == @condition_counts[decision_id] && values.all? do |item|
|
|
266
|
+
[true, false, nil].include?(item)
|
|
267
|
+
end
|
|
268
|
+
fail_with("invalid candidate values")
|
|
269
|
+
end
|
|
270
|
+
fail_with("invalid candidate outcome") unless [true, false].include?(candidate["outcome"])
|
|
271
|
+
end
|
|
272
|
+
existing = constraint["existing_vector_id"]
|
|
273
|
+
fail_with("unknown constraint vector") if existing && @vector_decision_by_id[existing] != decision_id
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def validate_analysis_coverage
|
|
277
|
+
analysis = @document["analysis"]
|
|
278
|
+
return unless analysis && analysis.dig("completeness", "analysis") == true
|
|
279
|
+
|
|
280
|
+
results = analysis["decisions"].to_h { |decision| [decision["decision_id"], decision["condition_results"]] }
|
|
281
|
+
@supported_ids.each do |id|
|
|
282
|
+
actual = Array(results[id]).map { |result| result["condition_id"] }.sort
|
|
283
|
+
fail_with("complete analysis is missing condition results") unless actual == @conditions_by_decision[id].sort
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def validate_string_field(record, field, nullable: false)
|
|
288
|
+
return unless record.key?(field)
|
|
289
|
+
return if nullable && record[field].nil?
|
|
290
|
+
|
|
291
|
+
fail_with("#{field} must be a string") unless record[field].is_a?(String)
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def validate_integer_field(record, field, nullable: false)
|
|
295
|
+
return unless record.key?(field)
|
|
296
|
+
return if nullable && record[field].nil?
|
|
297
|
+
|
|
298
|
+
fail_with("#{field} must be an integer") unless record[field].is_a?(Integer)
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def validate_metadata(metadata)
|
|
302
|
+
string_fields = %w[captured_at project_kind project_root]
|
|
303
|
+
string_fields.each do |field|
|
|
304
|
+
fail_with("run_metadata #{field} must be a string") if metadata.key?(field) && !metadata[field].is_a?(String)
|
|
305
|
+
end
|
|
306
|
+
array_fields = %w[source_patterns test_patterns test_files runner_args]
|
|
307
|
+
array_fields.each do |field|
|
|
308
|
+
if metadata.key?(field) && !strings?(metadata[field])
|
|
309
|
+
fail_with("run_metadata #{field} must be an array of strings")
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
if metadata.key?("requested_level") && !metadata["requested_level"].is_a?(Integer)
|
|
313
|
+
fail_with("run_metadata requested_level must be an integer")
|
|
314
|
+
end
|
|
315
|
+
if metadata.key?("limits") && !hash_with_string_keys?(metadata["limits"])
|
|
316
|
+
fail_with("run_metadata limits must be an object")
|
|
317
|
+
end
|
|
318
|
+
locations = metadata["test_locations"]
|
|
319
|
+
return unless locations
|
|
320
|
+
|
|
321
|
+
valid = hash_with_string_keys?(locations) && locations.all? do |test_id, location|
|
|
322
|
+
@test_ids.include?(test_id) && hash_with_string_keys?(location) &&
|
|
323
|
+
(!location.key?("relative_path") || location["relative_path"].nil? ||
|
|
324
|
+
location["relative_path"].is_a?(String)) &&
|
|
325
|
+
(!location.key?("line") || location["line"].nil? || location["line"].is_a?(Integer))
|
|
326
|
+
end
|
|
327
|
+
fail_with("invalid run_metadata test_locations") unless valid
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def unique_ids(items, field, label)
|
|
331
|
+
ids = items.map do |item|
|
|
332
|
+
unless hash_with_string_keys?(item) && item[field].is_a?(String) && !item[field].empty?
|
|
333
|
+
fail_with("invalid #{label} record")
|
|
334
|
+
end
|
|
335
|
+
item[field]
|
|
336
|
+
end
|
|
337
|
+
fail_with("duplicate #{label} id") unless ids.uniq.length == ids.length
|
|
338
|
+
ids
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def strings?(value)
|
|
342
|
+
value.is_a?(Array) && value.all?(String)
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def hash_with_string_keys?(value)
|
|
346
|
+
value.is_a?(Hash) && value.keys.all?(String)
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def all_hashes_have_string_keys?(value)
|
|
350
|
+
return true unless value.is_a?(Hash)
|
|
351
|
+
return false unless hash_with_string_keys?(value)
|
|
352
|
+
|
|
353
|
+
value.values.all? do |item|
|
|
354
|
+
all_hashes_have_string_keys?(item) || (item.is_a?(Array) && item.all? do |entry|
|
|
355
|
+
all_hashes_have_string_keys?(entry)
|
|
356
|
+
end)
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
def fail_with(message)
|
|
361
|
+
raise ArgumentError, "invalid saved report: #{message}"
|
|
362
|
+
end
|
|
363
|
+
end
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
# rubocop:enable Metrics/ClassLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
data/lib/branchproof/source.rb
CHANGED
|
@@ -134,6 +134,7 @@ module Branchproof
|
|
|
134
134
|
location = leaf.delete(:_location)
|
|
135
135
|
literal_truth = leaf.delete(:_literal_truth)
|
|
136
136
|
Records.build(id: nil, index: index, byte_start: location.start_offset, byte_length: location.length,
|
|
137
|
+
line: location.start_line, column: location.start_column,
|
|
137
138
|
expression: expression, literal_truth: literal_truth, coupling: "unknown")
|
|
138
139
|
end
|
|
139
140
|
decision_id = Records.decision_id(source_id: source_id, context: context, byte_start: start_offset,
|
data/lib/branchproof/version.rb
CHANGED
data/lib/branchproof.rb
CHANGED
|
@@ -18,6 +18,11 @@ module Branchproof
|
|
|
18
18
|
autoload :Analyzer, "branchproof/analyzer"
|
|
19
19
|
autoload :Minimizer, "branchproof/minimizer"
|
|
20
20
|
autoload :Report, "branchproof/report"
|
|
21
|
+
autoload :CoverageIndex, "branchproof/coverage_index"
|
|
22
|
+
autoload :FocusedReport, "branchproof/focused_report"
|
|
23
|
+
autoload :SavedReport, "branchproof/saved_report"
|
|
24
|
+
autoload :Comparison, "branchproof/comparison"
|
|
25
|
+
autoload :ComparisonReport, "branchproof/comparison_report"
|
|
21
26
|
autoload :Worker, "branchproof/worker"
|
|
22
27
|
end
|
|
23
28
|
|
data/llms.txt
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
| | |
|
|
4
4
|
| --- | --- |
|
|
5
|
-
| **Defined in** | lib/branchproof.rb, lib/branchproof/cli.rb, lib/branchproof/limits.rb, lib/branchproof/loader.rb, lib/branchproof/report.rb, lib/branchproof/source.rb, lib/branchproof/worker.rb, lib/branchproof/project.rb, lib/branchproof/records.rb, lib/branchproof/runtime.rb, lib/branchproof/version.rb, lib/branchproof/analyzer.rb, lib/branchproof/evidence.rb, lib/branchproof/minimizer.rb, lib/branchproof/instrumenter.rb, lib/branchproof/rails_support.rb, lib/branchproof/minitest_adapter.rb |
|
|
5
|
+
| **Defined in** | lib/branchproof.rb, lib/branchproof/cli.rb, lib/branchproof/limits.rb, lib/branchproof/loader.rb, lib/branchproof/report.rb, lib/branchproof/source.rb, lib/branchproof/worker.rb, lib/branchproof/project.rb, lib/branchproof/records.rb, lib/branchproof/runtime.rb, lib/branchproof/version.rb, lib/branchproof/analyzer.rb, lib/branchproof/evidence.rb, lib/branchproof/minimizer.rb, lib/branchproof/comparison.rb, lib/branchproof/instrumenter.rb, lib/branchproof/saved_report.rb, lib/branchproof/rails_support.rb, lib/branchproof/coverage_index.rb, lib/branchproof/focused_report.rb, lib/branchproof/minitest_adapter.rb, lib/branchproof/comparison_report.rb |
|
|
6
6
|
|
|
7
7
|
Public namespace for source inventory and one-run MC/DC reporting.
|
|
8
8
|
|
|
@@ -14,8 +14,12 @@ Not documented.
|
|
|
14
14
|
|
|
15
15
|
- [Branchproof/Analyzer.md](doc/Branchproof/Analyzer.md)
|
|
16
16
|
- [Branchproof/CLI.md](doc/Branchproof/CLI.md)
|
|
17
|
+
- [Branchproof/Comparison.md](doc/Branchproof/Comparison.md)
|
|
18
|
+
- [Branchproof/ComparisonReport.md](doc/Branchproof/ComparisonReport.md)
|
|
19
|
+
- [Branchproof/CoverageIndex.md](doc/Branchproof/CoverageIndex.md)
|
|
17
20
|
- [Branchproof/Error.md](doc/Branchproof/Error.md)
|
|
18
21
|
- [Branchproof/Evidence.md](doc/Branchproof/Evidence.md)
|
|
22
|
+
- [Branchproof/FocusedReport.md](doc/Branchproof/FocusedReport.md)
|
|
19
23
|
- [Branchproof/Instrumenter.md](doc/Branchproof/Instrumenter.md)
|
|
20
24
|
- [Branchproof/Limits.md](doc/Branchproof/Limits.md)
|
|
21
25
|
- [Branchproof/Loader.md](doc/Branchproof/Loader.md)
|
|
@@ -27,6 +31,7 @@ Not documented.
|
|
|
27
31
|
- [Branchproof/Records.md](doc/Branchproof/Records.md)
|
|
28
32
|
- [Branchproof/Report.md](doc/Branchproof/Report.md)
|
|
29
33
|
- [Branchproof/Runtime.md](doc/Branchproof/Runtime.md)
|
|
34
|
+
- [Branchproof/SavedReport.md](doc/Branchproof/SavedReport.md)
|
|
30
35
|
- [Branchproof/Source.md](doc/Branchproof/Source.md)
|
|
31
36
|
- [Branchproof/Worker.md](doc/Branchproof/Worker.md)
|
|
32
37
|
- [CHANGELOG.md](doc/CHANGELOG.md)
|
data/sig/branchproof.rbs
CHANGED
|
@@ -2,6 +2,7 @@ module Branchproof
|
|
|
2
2
|
VERSION: String
|
|
3
3
|
|
|
4
4
|
type record = Hash[Symbol, untyped]
|
|
5
|
+
type document = Hash[String | Symbol, untyped]
|
|
5
6
|
type limits = record
|
|
6
7
|
|
|
7
8
|
class Error < StandardError
|
|
@@ -102,11 +103,40 @@ module Branchproof
|
|
|
102
103
|
class Report
|
|
103
104
|
SCHEMA_VERSION: String
|
|
104
105
|
CRITERION_VERSION: String
|
|
105
|
-
def initialize: (inventory:
|
|
106
|
+
def initialize: (inventory: document, evidence: document, analysis: document?, minima: Array[document], baseline: document, diagnostics: Array[document], ?level: Integer, ?missing_only: bool, ?view: Symbol, ?run_metadata: document, ?saved_document: document?) -> void
|
|
107
|
+
def self.from_document: (document: document, ?level: Integer?, ?view: Symbol, ?missing_only: bool) -> Report
|
|
108
|
+
def diagnostic_message: (Hash[Symbol | String, untyped] diagnostic) -> String
|
|
109
|
+
def condition_explanation: (decision_id: String, condition_id: String) -> String
|
|
106
110
|
def write: (io: _WriteIO, format: Symbol | String) -> nil
|
|
107
111
|
def exit_code: () -> Integer
|
|
108
112
|
end
|
|
109
113
|
|
|
114
|
+
class CoverageIndex
|
|
115
|
+
def initialize: (document: document) -> void
|
|
116
|
+
attr_reader conditions: Array[record]
|
|
117
|
+
attr_reader tests: Array[record]
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
class FocusedReport
|
|
121
|
+
def initialize: (document: document, view: Symbol, level: Integer, ?missing_only: bool) -> void
|
|
122
|
+
def render: () -> String
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
class SavedReport
|
|
126
|
+
def self.read: (String path) -> document
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
class Comparison
|
|
130
|
+
def initialize: (before: document, after: document) -> void
|
|
131
|
+
def call: () -> document
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
class ComparisonReport
|
|
135
|
+
def initialize: (document: document) -> void
|
|
136
|
+
def write: (io: _WriteIO, format: Symbol | String) -> nil
|
|
137
|
+
def exit_code: (?fail_on_regression: bool) -> Integer
|
|
138
|
+
end
|
|
139
|
+
|
|
110
140
|
interface _WriteIO
|
|
111
141
|
def write: (String) -> Integer
|
|
112
142
|
end
|