branchproof 0.9.0 → 0.11.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 +42 -0
- data/README.md +230 -28
- data/doc/Branchproof/Configuration.md +28 -0
- data/doc/Branchproof/CoveragePolicy.md +27 -0
- data/doc/Branchproof/Evidence.md +3 -0
- data/doc/Branchproof/FocusedReport.md +1 -1
- data/doc/Branchproof/Project.md +4 -1
- data/doc/Branchproof/RSpecAdapter/ClassRunnerGuard.md +11 -0
- data/doc/Branchproof/RSpecAdapter/ContextLifecycle.md +14 -0
- data/doc/Branchproof/RSpecAdapter/DefaultDiscovery.md +11 -0
- data/doc/Branchproof/RSpecAdapter/ExampleLifecycle.md +11 -0
- data/doc/Branchproof/RSpecAdapter/RunnerGuard.md +11 -0
- data/doc/Branchproof/RSpecAdapter/UnsupportedRunner.md +12 -0
- data/doc/Branchproof/RSpecAdapter.md +47 -0
- data/doc/Branchproof/RailsSupport.md +35 -2
- data/doc/Branchproof/Report.md +11 -2
- data/doc/Branchproof/ReportSelection.md +42 -0
- data/doc/Branchproof/Runtime.md +3 -0
- data/doc/Branchproof/Worker.md +8 -1
- data/doc/Branchproof.md +11 -1
- data/doc/CHANGELOG.md +42 -0
- data/doc/README.md +230 -28
- data/lib/branchproof/cli.rb +191 -38
- data/lib/branchproof/comparison.rb +58 -7
- data/lib/branchproof/configuration.rb +111 -0
- data/lib/branchproof/coverage_policy.rb +169 -0
- data/lib/branchproof/evidence.rb +41 -2
- data/lib/branchproof/focused_report.rb +149 -27
- data/lib/branchproof/project.rb +49 -2
- data/lib/branchproof/rails_support.rb +101 -0
- data/lib/branchproof/report.rb +118 -10
- data/lib/branchproof/report_selection.rb +158 -0
- data/lib/branchproof/rspec_adapter.rb +423 -0
- data/lib/branchproof/runtime.rb +4 -0
- data/lib/branchproof/saved_report.rb +38 -5
- data/lib/branchproof/version.rb +1 -1
- data/lib/branchproof/worker.rb +75 -15
- data/lib/branchproof.rb +4 -0
- data/llms.txt +11 -1
- data/sig/branchproof.rbs +63 -4
- metadata +16 -2
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# rubocop:disable Metrics/ClassLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/ParameterLists
|
|
4
|
+
|
|
5
|
+
module Branchproof
|
|
6
|
+
# Evaluates configured coverage thresholds against the aggregate Analyzer
|
|
7
|
+
# coverage counts without relying on rounded percentage fields.
|
|
8
|
+
class CoveragePolicy
|
|
9
|
+
CRITERIA = {
|
|
10
|
+
"decision" => { numerator: "covered_decisions", denominator: "supported_decisions" },
|
|
11
|
+
"condition" => { numerator: "covered_values", denominator: "required_values" },
|
|
12
|
+
"condition_decision" => { numerator: "covered_decisions", denominator: "supported_decisions" },
|
|
13
|
+
"mcdc" => { numerator: "proven_conditions", denominator: "supported_conditions" },
|
|
14
|
+
"decision_table" => { numerator: "covered_rules", denominator: "required_rules" }
|
|
15
|
+
}.freeze
|
|
16
|
+
COMPLETENESS_FIELDS = %w[observation attribution analysis].freeze
|
|
17
|
+
|
|
18
|
+
class << self
|
|
19
|
+
def normalize(minimum)
|
|
20
|
+
raise ArgumentError, "minimum must be a hash" unless minimum.is_a?(Hash)
|
|
21
|
+
|
|
22
|
+
minimum.each_with_object({}) do |(key, threshold), normalized|
|
|
23
|
+
unless key.is_a?(String) || key.is_a?(Symbol)
|
|
24
|
+
raise ArgumentError, "unknown coverage criterion: #{key.inspect}"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
criterion = key.to_s
|
|
28
|
+
raise ArgumentError, "unknown coverage criterion: #{criterion}" unless CRITERIA.key?(criterion)
|
|
29
|
+
raise ArgumentError, "duplicate coverage criterion: #{criterion}" if normalized.key?(criterion)
|
|
30
|
+
|
|
31
|
+
validate_threshold!(criterion, threshold)
|
|
32
|
+
normalized[criterion] = threshold
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def validate_threshold!(criterion, threshold)
|
|
39
|
+
valid_type = threshold.is_a?(Integer) || threshold.is_a?(Float)
|
|
40
|
+
valid_float = !threshold.is_a?(Float) || threshold.finite?
|
|
41
|
+
unless valid_type && valid_float
|
|
42
|
+
raise ArgumentError, "coverage minimum for #{criterion} must be a finite number"
|
|
43
|
+
end
|
|
44
|
+
return if (0..100).cover?(threshold)
|
|
45
|
+
|
|
46
|
+
raise ArgumentError, "coverage minimum for #{criterion} must be between 0 and 100"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def initialize(minimum:)
|
|
51
|
+
@minimum = self.class.normalize(minimum)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def call(document:)
|
|
55
|
+
return { minimum: @minimum.dup, status: "passed", gates: [] } if @minimum.empty?
|
|
56
|
+
|
|
57
|
+
global_reason = availability_reason(document)
|
|
58
|
+
gates = @minimum.map do |criterion, threshold|
|
|
59
|
+
evaluate_gate(criterion, threshold, document, global_reason)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
status = if gates.any? { |gate| gate[:status] == "unavailable" }
|
|
63
|
+
"unavailable"
|
|
64
|
+
elsif gates.any? { |gate| gate[:status] == "failed" }
|
|
65
|
+
"failed"
|
|
66
|
+
else
|
|
67
|
+
"passed"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
{ minimum: @minimum.dup, status: status, gates: gates }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def availability_reason(document)
|
|
76
|
+
return "document_unavailable" unless document.is_a?(Hash)
|
|
77
|
+
|
|
78
|
+
baseline = value(document, :baseline)
|
|
79
|
+
baseline_passed = baseline.is_a?(Hash) && value(baseline, :status).to_s.upcase == "PASSED"
|
|
80
|
+
return "baseline_unavailable" unless baseline_passed && value(baseline, :finalized) == true
|
|
81
|
+
return "incomplete_document" unless complete?(value(document, :completeness))
|
|
82
|
+
|
|
83
|
+
observations = value(document, :observations) || value(document, :evidence)
|
|
84
|
+
return "incomplete_observations" if incomplete_section?(observations)
|
|
85
|
+
|
|
86
|
+
analysis = value(document, :analysis)
|
|
87
|
+
return "incomplete_analysis" if incomplete_section?(analysis)
|
|
88
|
+
|
|
89
|
+
nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def complete?(completeness)
|
|
93
|
+
return false unless completeness.is_a?(Hash)
|
|
94
|
+
|
|
95
|
+
COMPLETENESS_FIELDS.all? { |field| value(completeness, field) == true }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def incomplete_section?(section)
|
|
99
|
+
section.is_a?(Hash) && key_present?(section, :completeness) && !complete?(value(section, :completeness))
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def evaluate_gate(criterion, threshold, document, global_reason)
|
|
103
|
+
return gate(criterion, threshold, nil, nil, "unavailable", global_reason) if global_reason
|
|
104
|
+
|
|
105
|
+
coverage = value(value(document, :analysis), :coverage)
|
|
106
|
+
row = value(coverage, criterion)
|
|
107
|
+
return gate(criterion, threshold, nil, nil, "unavailable", "missing_coverage_count") unless row.is_a?(Hash)
|
|
108
|
+
|
|
109
|
+
mapping = CRITERIA.fetch(criterion)
|
|
110
|
+
numerator = value(row, mapping.fetch(:numerator))
|
|
111
|
+
denominator = value(row, mapping.fetch(:denominator))
|
|
112
|
+
|
|
113
|
+
if criterion == "decision_table"
|
|
114
|
+
not_calculated = value(row, :not_calculated_decisions)
|
|
115
|
+
unless not_calculated.nil?
|
|
116
|
+
unless valid_count?(not_calculated)
|
|
117
|
+
return gate(criterion, threshold, numerator, denominator, "unavailable", "invalid_coverage_count")
|
|
118
|
+
end
|
|
119
|
+
if not_calculated.positive?
|
|
120
|
+
return gate(criterion, threshold, numerator, denominator, "unavailable", "decision_table_not_calculated")
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
if numerator.nil? || denominator.nil?
|
|
126
|
+
return unavailable_gate(criterion, threshold, numerator, denominator, "missing_coverage_count")
|
|
127
|
+
end
|
|
128
|
+
unless valid_count?(numerator) && valid_count?(denominator)
|
|
129
|
+
return unavailable_gate(criterion, threshold, numerator, denominator, "invalid_coverage_count")
|
|
130
|
+
end
|
|
131
|
+
return unavailable_gate(criterion, threshold, numerator, denominator, "zero_denominator") if denominator.zero?
|
|
132
|
+
if numerator > denominator
|
|
133
|
+
return unavailable_gate(criterion, threshold, numerator, denominator, "invalid_coverage_count")
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
status = numerator * 100 >= denominator * Rational(threshold.to_s) ? "passed" : "failed"
|
|
137
|
+
gate(criterion, threshold, numerator, denominator, status, nil)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def valid_count?(value)
|
|
141
|
+
value.is_a?(Integer) && value >= 0
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def gate(criterion, threshold, numerator, denominator, status, reason)
|
|
145
|
+
{ criterion: criterion, numerator: numerator, denominator: denominator, minimum: threshold,
|
|
146
|
+
status: status, reason: reason }
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def unavailable_gate(criterion, threshold, numerator, denominator, reason)
|
|
150
|
+
gate(criterion, threshold, numerator, denominator, "unavailable", reason)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def value(hash, key)
|
|
154
|
+
return nil unless hash.respond_to?(:key?)
|
|
155
|
+
return hash[key] if hash.key?(key)
|
|
156
|
+
|
|
157
|
+
alternate = key.is_a?(Symbol) ? key.to_s : key.to_sym
|
|
158
|
+
return hash[alternate] if hash.key?(alternate)
|
|
159
|
+
|
|
160
|
+
nil
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def key_present?(hash, key)
|
|
164
|
+
hash.key?(key) || hash.key?(key.to_s) || hash.key?(key.to_sym)
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# rubocop:enable Metrics/ClassLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/ParameterLists
|
data/lib/branchproof/evidence.rb
CHANGED
|
@@ -50,7 +50,9 @@ module Branchproof
|
|
|
50
50
|
current = @tests[id] || { id: id, adapter: "unknown", name: id, source: nil, class_name: nil,
|
|
51
51
|
method_name: nil, status: "unknown", phase_counts: {} }
|
|
52
52
|
merged = current.merge(value).merge(id: id)
|
|
53
|
-
merged[:phase_counts] = (current[:phase_counts]
|
|
53
|
+
merged[:phase_counts] = normalize_phase_counts(current[:phase_counts]).merge(
|
|
54
|
+
normalize_phase_counts(value[:phase_counts])
|
|
55
|
+
)
|
|
54
56
|
@tests[id] = deep_dup(merged)
|
|
55
57
|
status("registered", nil)
|
|
56
58
|
end
|
|
@@ -120,6 +122,10 @@ module Branchproof
|
|
|
120
122
|
nil
|
|
121
123
|
end
|
|
122
124
|
|
|
125
|
+
def test_phase_counts
|
|
126
|
+
@tests.transform_values { |test| test[:phase_counts].dup.freeze }.freeze
|
|
127
|
+
end
|
|
128
|
+
|
|
123
129
|
def snapshot
|
|
124
130
|
deep_freeze(deep_dup({ schema_version: SCHEMA_VERSION, tool_version: TOOL_VERSION,
|
|
125
131
|
criterion_version: CRITERION_VERSION,
|
|
@@ -168,7 +174,7 @@ module Branchproof
|
|
|
168
174
|
@run_payloads[id] = Branchproof::Records.id(payload)
|
|
169
175
|
end
|
|
170
176
|
@run_ids |= incoming_runs
|
|
171
|
-
incoming.fetch(:tests, []).each {
|
|
177
|
+
incoming.fetch(:tests, []).each { merge_test(_1) }
|
|
172
178
|
incoming.fetch(:vectors, []).each { merge_vector(_1) }
|
|
173
179
|
@diagnostics.concat(incoming.fetch(:diagnostics, []))
|
|
174
180
|
@abort_counts.merge!(incoming.fetch(:abort_counts, {})) { |_k, a, b| a.to_i + b.to_i }
|
|
@@ -333,6 +339,39 @@ module Branchproof
|
|
|
333
339
|
end
|
|
334
340
|
end
|
|
335
341
|
|
|
342
|
+
def merge_test(raw)
|
|
343
|
+
value = symbolize(raw)
|
|
344
|
+
value[:phase_counts] = normalize_phase_counts(value[:phase_counts])
|
|
345
|
+
id = (value[:id] || test_id(value)).to_s
|
|
346
|
+
unless @tests.key?(id)
|
|
347
|
+
register_test(test: value)
|
|
348
|
+
return unless @tests.key?(id)
|
|
349
|
+
|
|
350
|
+
return
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
current = @tests.fetch(id)
|
|
354
|
+
merged = current.merge(value).merge(id: id)
|
|
355
|
+
merged[:phase_counts] = sum_phase_counts(current[:phase_counts], value[:phase_counts])
|
|
356
|
+
@tests[id] = deep_dup(merged)
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def normalize_phase_counts(value)
|
|
360
|
+
(value || {}).each_with_object({}) do |(phase, count), result|
|
|
361
|
+
key = phase.to_s
|
|
362
|
+
result[key] = result.fetch(key, 0) + count
|
|
363
|
+
end
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
def sum_phase_counts(*counts)
|
|
367
|
+
counts.each_with_object({}) do |phase_counts, result|
|
|
368
|
+
normalize_phase_counts(phase_counts).each do |phase, count|
|
|
369
|
+
result[phase] = result.fetch(phase, 0) + count
|
|
370
|
+
end
|
|
371
|
+
result
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
|
|
336
375
|
def merge_error(incoming)
|
|
337
376
|
return "unsupported schema" unless incoming[:schema_version].to_s == SCHEMA_VERSION
|
|
338
377
|
return "criterion mismatch" unless incoming[:criterion_version].to_s == CRITERION_VERSION
|
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# rubocop:disable Metrics/ClassLength, Metrics/AbcSize, Metrics/BlockLength, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
3
|
+
# rubocop:disable Metrics/ClassLength, Metrics/AbcSize, Metrics/BlockLength, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/ParameterLists, Metrics/PerceivedComplexity
|
|
4
4
|
require "pathname"
|
|
5
|
+
require_relative "coverage_index"
|
|
6
|
+
require_relative "report_selection"
|
|
5
7
|
|
|
6
8
|
module Branchproof
|
|
7
9
|
# Terminal renderings grouped around conditions or tests.
|
|
8
10
|
class FocusedReport
|
|
9
11
|
DECISION_TABLE_LABELS = { "true" => "T", "false" => "F", "dont_care" => "-" }.freeze
|
|
10
12
|
|
|
11
|
-
def initialize(document:, view:, level:, missing_only: false, coordinator: nil
|
|
13
|
+
def initialize(document:, view:, level:, missing_only: false, coordinator: nil, focus: nil, top: nil,
|
|
14
|
+
selection: nil)
|
|
12
15
|
@document = document || {}
|
|
13
16
|
@view = view.to_sym
|
|
14
17
|
unless (Report::VIEWS - [:decisions]).include?(@view)
|
|
@@ -17,12 +20,14 @@ module Branchproof
|
|
|
17
20
|
|
|
18
21
|
@level = level.to_i
|
|
19
22
|
@missing_only = missing_only ? true : false
|
|
23
|
+
@selection = selection || ReportSelection.new(focus: focus, top: top)
|
|
20
24
|
@index = CoverageIndex.new(document: @document)
|
|
21
25
|
@tests = @index.tests.to_h { |test| [test[:id].to_s, test] }
|
|
22
26
|
@test_name_counts = @index.tests.each_with_object(Hash.new(0)) do |test, counts|
|
|
23
27
|
counts[[test[:name], test[:relative_path], test[:line]]] += 1
|
|
24
28
|
end
|
|
25
29
|
@coordinator = coordinator || Report.from_document(document: @document, level: @level)
|
|
30
|
+
@matching_decision_ids = @selection.focus_active? ? @selection.matching_decision_ids(@document) : nil
|
|
26
31
|
end
|
|
27
32
|
|
|
28
33
|
def render
|
|
@@ -45,20 +50,19 @@ module Branchproof
|
|
|
45
50
|
end
|
|
46
51
|
lines << "Empty groups mean no recorded completed observation."
|
|
47
52
|
lines << ""
|
|
53
|
+
render_focus_notice(lines)
|
|
48
54
|
lines.concat(@coordinator.coverage_ladder_lines)
|
|
55
|
+
lines.concat(@coordinator.coverage_policy_lines)
|
|
49
56
|
case @view
|
|
50
57
|
when :conditions
|
|
51
58
|
render_conditions(lines)
|
|
52
|
-
render_alternatives(lines)
|
|
53
59
|
when :decision_tables
|
|
54
60
|
render_decision_tables(lines)
|
|
55
61
|
else
|
|
56
62
|
render_tests(lines)
|
|
57
63
|
end
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
render_unsupported(lines)
|
|
61
|
-
end
|
|
64
|
+
render_unowned(lines)
|
|
65
|
+
render_unsupported(lines)
|
|
62
66
|
Array(fetch(@document, :diagnostics)).each do |diagnostic|
|
|
63
67
|
lines << "Diagnostic: #{@coordinator.diagnostic_message(diagnostic)}"
|
|
64
68
|
end
|
|
@@ -68,8 +72,8 @@ module Branchproof
|
|
|
68
72
|
private
|
|
69
73
|
|
|
70
74
|
def render_conditions(lines)
|
|
71
|
-
rows =
|
|
72
|
-
|
|
75
|
+
rows, alternatives, hidden = condition_rows_for_display
|
|
76
|
+
append_limit_summary(lines, hidden, "condition/alternative row")
|
|
73
77
|
rows.each do |row|
|
|
74
78
|
lines << "Condition: #{row[:expression]}"
|
|
75
79
|
lines << "Location: #{location(row[:relative_path], row[:line], unavailable: "condition line unavailable")}"
|
|
@@ -108,7 +112,8 @@ module Branchproof
|
|
|
108
112
|
end
|
|
109
113
|
lines << ""
|
|
110
114
|
end
|
|
111
|
-
lines << "No missing conditions" if @missing_only && rows.empty?
|
|
115
|
+
lines << "No missing conditions" if @missing_only && rows.empty? && focus_match_exists?
|
|
116
|
+
render_alternatives(lines, alternatives)
|
|
112
117
|
end
|
|
113
118
|
|
|
114
119
|
def coverage_available?
|
|
@@ -133,11 +138,12 @@ module Branchproof
|
|
|
133
138
|
# --missing-only only uncovered, non-impossible rules remain: a rule proven
|
|
134
139
|
# impossible is not a missing obligation.
|
|
135
140
|
def render_decision_tables(lines)
|
|
136
|
-
|
|
141
|
+
all_rows = filtered_decision_tables
|
|
142
|
+
rows, hidden = limit_rows(all_rows)
|
|
143
|
+
append_limit_summary(lines, hidden, "decision")
|
|
137
144
|
rendered = 0
|
|
138
|
-
impossible =
|
|
145
|
+
impossible = @index.decision_tables.sum { |row| row[:impossible_rules].to_i }
|
|
139
146
|
rows.each do |row|
|
|
140
|
-
impossible += row[:impossible_rules].to_i
|
|
141
147
|
rules = @missing_only ? row[:rules].select { |rule| rule[:coverage].to_s == "missing" } : row[:rules]
|
|
142
148
|
next if @missing_only && rules.empty? && row[:status].to_s == "calculated"
|
|
143
149
|
|
|
@@ -187,9 +193,8 @@ module Branchproof
|
|
|
187
193
|
lines << " Reason: #{Constraints.message(rule[:reachability_reason])}"
|
|
188
194
|
end
|
|
189
195
|
|
|
190
|
-
def render_alternatives(lines)
|
|
191
|
-
rows
|
|
192
|
-
rows = rows.select { |row| row[:missing] || row[:status].to_s != "covered" } if @missing_only && @level > 1
|
|
196
|
+
def render_alternatives(lines, rows = nil)
|
|
197
|
+
rows ||= filtered_alternatives
|
|
193
198
|
rows.each do |row|
|
|
194
199
|
lines << "Alternative #{row[:index]}: #{row[:expression]}"
|
|
195
200
|
lines << "Location: #{location(row[:relative_path], row[:line],
|
|
@@ -208,7 +213,7 @@ module Branchproof
|
|
|
208
213
|
end
|
|
209
214
|
lines << ""
|
|
210
215
|
end
|
|
211
|
-
lines << "No missing alternatives" if @missing_only && rows.empty?
|
|
216
|
+
lines << "No missing alternatives" if @missing_only && rows.empty? && focus_match_exists?
|
|
212
217
|
end
|
|
213
218
|
|
|
214
219
|
def render_alternative_group(lines, heading, evidence)
|
|
@@ -219,25 +224,27 @@ module Branchproof
|
|
|
219
224
|
end
|
|
220
225
|
|
|
221
226
|
def render_tests(lines)
|
|
222
|
-
rows =
|
|
227
|
+
rows = filtered_tests
|
|
223
228
|
missing_ids = @index.conditions.reject { |row| row[:status].to_s.upcase == "PROVEN" }.map { |row| row[:id] }
|
|
224
229
|
missing_alternative_ids = @index.alternatives.select { |row| row[:missing] || row[:status].to_s != "covered" }
|
|
225
230
|
.map { |row| row[:alternative_id] }
|
|
231
|
+
rows = rows.select do |row|
|
|
232
|
+
!@missing_only || test_observations(row, missing_ids, missing_alternative_ids).any?
|
|
233
|
+
end
|
|
234
|
+
rows, hidden = limit_rows(rows)
|
|
235
|
+
append_limit_summary(lines, hidden, "test")
|
|
226
236
|
rows.each { |row| render_test_row(lines, row, missing_ids, missing_alternative_ids) }
|
|
227
237
|
end
|
|
228
238
|
|
|
229
239
|
def render_test_row(lines, row, missing_ids, missing_alternative_ids = [])
|
|
230
|
-
observations = row
|
|
231
|
-
if @missing_only
|
|
232
|
-
|
|
233
|
-
missing_ids.include?(observation[:condition_id]) ||
|
|
234
|
-
missing_alternative_ids.include?(observation[:alternative_id])
|
|
235
|
-
end
|
|
236
|
-
return if observations.empty?
|
|
237
|
-
end
|
|
240
|
+
observations = test_observations(row, missing_ids, missing_alternative_ids)
|
|
241
|
+
return if @missing_only && observations.empty?
|
|
242
|
+
|
|
238
243
|
lines << "Test: #{row[:name]}"
|
|
239
244
|
lines << "Location: #{location(row[:relative_path], row[:line], unavailable: "location unavailable")}"
|
|
240
245
|
lines << "Status: #{row[:status] || "unknown"}"
|
|
246
|
+
command = @coordinator.rerun_command(row[:id])
|
|
247
|
+
lines << "Rerun: #{command}" if command
|
|
241
248
|
lines << "Phases: #{row[:phases].join(", ")}" unless row[:phases].empty?
|
|
242
249
|
render_test_observations(lines, observations)
|
|
243
250
|
lines << ""
|
|
@@ -289,6 +296,11 @@ module Branchproof
|
|
|
289
296
|
groups.each do |heading, conditions|
|
|
290
297
|
next if conditions.empty?
|
|
291
298
|
|
|
299
|
+
if @selection.active?
|
|
300
|
+
lines << "#{heading}: #{conditions.length} (run-wide)"
|
|
301
|
+
next
|
|
302
|
+
end
|
|
303
|
+
|
|
292
304
|
lines << "#{heading}:"
|
|
293
305
|
conditions.each do |row|
|
|
294
306
|
unavailable = row[:alternative_id] ? "alternative location unavailable" : "condition line unavailable"
|
|
@@ -305,6 +317,12 @@ module Branchproof
|
|
|
305
317
|
end
|
|
306
318
|
return if excluded.empty?
|
|
307
319
|
|
|
320
|
+
if @selection.active?
|
|
321
|
+
noun = excluded.length == 1 ? "decision" : "decisions"
|
|
322
|
+
lines << "Unsupported conditions (MC/DC unavailable): #{excluded.length} #{noun} (run-wide)"
|
|
323
|
+
return
|
|
324
|
+
end
|
|
325
|
+
|
|
308
326
|
lines << "Unsupported conditions (MC/DC unavailable):"
|
|
309
327
|
excluded.each do |decision|
|
|
310
328
|
source = sources[fetch(decision, :source_id)] || {}
|
|
@@ -319,6 +337,107 @@ module Branchproof
|
|
|
319
337
|
end
|
|
320
338
|
end
|
|
321
339
|
|
|
340
|
+
def render_focus_notice(lines)
|
|
341
|
+
return unless @selection.focus_active?
|
|
342
|
+
|
|
343
|
+
lines << if @matching_decision_ids.empty?
|
|
344
|
+
"Focus: no matching decisions for #{@selection.focus_label}"
|
|
345
|
+
else
|
|
346
|
+
"Focus: #{@selection.focus_label}"
|
|
347
|
+
end
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def append_limit_summary(lines, hidden, unit)
|
|
351
|
+
return unless @selection.top
|
|
352
|
+
|
|
353
|
+
noun = hidden == 1 ? unit : "#{unit}s"
|
|
354
|
+
hidden_noun = hidden == 1 ? unit : "#{unit}s"
|
|
355
|
+
lines << "Display limit: top #{@selection.top} #{noun}; hidden #{hidden} #{hidden_noun} " \
|
|
356
|
+
"(not risk-ranked)"
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def focus_match_exists?
|
|
360
|
+
!@selection.focus_active? || !@matching_decision_ids.empty?
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def filtered_conditions
|
|
364
|
+
rows = @index.conditions
|
|
365
|
+
rows = rows.select { |row| @matching_decision_ids.include?(row[:decision_id].to_s) } if @selection.focus_active?
|
|
366
|
+
rows = rows.reject { |row| row[:status].to_s.upcase == "PROVEN" } if @missing_only && @level > 1
|
|
367
|
+
rows.sort_by do |row|
|
|
368
|
+
[row[:relative_path].to_s, row[:line].to_i, row[:column].to_i,
|
|
369
|
+
row[:decision_id].to_s, row[:index].to_i]
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
def filtered_alternatives
|
|
374
|
+
rows = @index.alternatives
|
|
375
|
+
rows = rows.select { |row| @matching_decision_ids.include?(row[:decision_id].to_s) } if @selection.focus_active?
|
|
376
|
+
rows = rows.select { |row| row[:missing] || row[:status].to_s != "covered" } if @missing_only && @level > 1
|
|
377
|
+
rows.sort_by do |row|
|
|
378
|
+
[row[:relative_path].to_s, row[:line].to_i, row[:column].to_i,
|
|
379
|
+
row[:decision_id].to_s, row[:index].to_i]
|
|
380
|
+
end
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
def condition_rows_for_display
|
|
384
|
+
conditions = filtered_conditions
|
|
385
|
+
alternatives = filtered_alternatives
|
|
386
|
+
return [conditions, alternatives, 0] unless @selection.top
|
|
387
|
+
|
|
388
|
+
combined = (conditions.map { |row| [:condition, row] } + alternatives.map { |row| [:alternative, row] })
|
|
389
|
+
.sort_by do |kind, row|
|
|
390
|
+
[row[:relative_path].to_s, row[:line].to_i, row[:column].to_i, kind == :condition ? 0 : 1,
|
|
391
|
+
row[:decision_id].to_s, row[:index].to_i]
|
|
392
|
+
end
|
|
393
|
+
visible, hidden = @selection.limit(combined)
|
|
394
|
+
[visible.filter_map { |kind, row| row if kind == :condition },
|
|
395
|
+
visible.filter_map { |kind, row| row if kind == :alternative }, hidden]
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
def filtered_decision_tables
|
|
399
|
+
rows = @index.decision_tables
|
|
400
|
+
rows = rows.select { |row| @matching_decision_ids.include?(row[:decision_id].to_s) } if @selection.focus_active?
|
|
401
|
+
rows = rows.select do |row|
|
|
402
|
+
!@missing_only || row[:status].to_s != "calculated" || row[:rules].any? do |rule|
|
|
403
|
+
rule[:coverage].to_s == "missing"
|
|
404
|
+
end
|
|
405
|
+
end
|
|
406
|
+
rows.sort_by { |row| [row[:relative_path].to_s, row[:line].to_i, row[:decision_id].to_s] }
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def filtered_tests
|
|
410
|
+
rows = @index.tests
|
|
411
|
+
return rows unless @selection.active?
|
|
412
|
+
|
|
413
|
+
if @selection.focus_active?
|
|
414
|
+
condition_ids = @index.conditions.select { |row| @matching_decision_ids.include?(row[:decision_id].to_s) }
|
|
415
|
+
.map { |row| row[:id].to_s }
|
|
416
|
+
alternative_ids = @index.alternatives.select { |row| @matching_decision_ids.include?(row[:decision_id].to_s) }
|
|
417
|
+
.map { |row| row[:alternative_id].to_s }
|
|
418
|
+
rows = rows.select do |row|
|
|
419
|
+
row[:observations].any? do |observation|
|
|
420
|
+
condition_ids.include?(observation[:condition_id].to_s) ||
|
|
421
|
+
alternative_ids.include?(observation[:alternative_id].to_s)
|
|
422
|
+
end
|
|
423
|
+
end
|
|
424
|
+
end
|
|
425
|
+
rows.sort_by { |row| [row[:relative_path].to_s, row[:line].to_i, row[:name].to_s, row[:id].to_s] }
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
def limit_rows(rows)
|
|
429
|
+
@selection.limit(rows)
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
def test_observations(row, missing_ids, missing_alternative_ids)
|
|
433
|
+
return row[:observations] unless @missing_only
|
|
434
|
+
|
|
435
|
+
row[:observations].select do |observation|
|
|
436
|
+
missing_ids.include?(observation[:condition_id]) ||
|
|
437
|
+
missing_alternative_ids.include?(observation[:alternative_id])
|
|
438
|
+
end
|
|
439
|
+
end
|
|
440
|
+
|
|
322
441
|
def location(path, line, unavailable: "location unavailable")
|
|
323
442
|
return "location unavailable" if path.to_s.empty?
|
|
324
443
|
return "#{path}: #{unavailable}" if line.nil?
|
|
@@ -341,6 +460,9 @@ module Branchproof
|
|
|
341
460
|
return id.to_s unless test
|
|
342
461
|
|
|
343
462
|
label = "#{test[:name]} (#{location(test[:relative_path], test[:line])})"
|
|
463
|
+
command = @coordinator.rerun_command(id)
|
|
464
|
+
return "#{label} (rerun: #{command})" if command
|
|
465
|
+
|
|
344
466
|
duplicates = @test_name_counts[[test[:name], test[:relative_path], test[:line]]]
|
|
345
467
|
duplicates > 1 ? "#{label} [#{id}]" : label
|
|
346
468
|
end
|
|
@@ -351,4 +473,4 @@ module Branchproof
|
|
|
351
473
|
end
|
|
352
474
|
end
|
|
353
475
|
|
|
354
|
-
# rubocop:enable Metrics/ClassLength, Metrics/AbcSize, Metrics/BlockLength, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
476
|
+
# rubocop:enable Metrics/ClassLength, Metrics/AbcSize, Metrics/BlockLength, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/ParameterLists, Metrics/PerceivedComplexity
|
data/lib/branchproof/project.rb
CHANGED
|
@@ -4,6 +4,7 @@ module Branchproof
|
|
|
4
4
|
# Resolves the project policy used by the isolated analysis worker.
|
|
5
5
|
class Project
|
|
6
6
|
MODES = %w[auto ruby rails].freeze
|
|
7
|
+
FRAMEWORKS = %w[auto minitest rspec].freeze
|
|
7
8
|
RAILS_ENVIRONMENT = {
|
|
8
9
|
"RAILS_ENV" => "test",
|
|
9
10
|
"RACK_ENV" => "test",
|
|
@@ -12,19 +13,24 @@ module Branchproof
|
|
|
12
13
|
"DISABLE_SPRING" => "1"
|
|
13
14
|
}.freeze
|
|
14
15
|
|
|
15
|
-
def initialize(root:, mode: "auto")
|
|
16
|
+
def initialize(root:, mode: "auto", framework: "auto")
|
|
16
17
|
@root = File.expand_path(root)
|
|
17
18
|
@mode = mode.to_s
|
|
19
|
+
@framework = framework.to_s
|
|
18
20
|
raise ArgumentError, "project must be auto, ruby, or rails" unless MODES.include?(@mode)
|
|
21
|
+
raise ArgumentError, "framework must be auto, minitest, or rspec" unless FRAMEWORKS.include?(@framework)
|
|
19
22
|
|
|
20
23
|
validate_root!
|
|
24
|
+
resolve_framework!
|
|
21
25
|
end
|
|
22
26
|
|
|
23
27
|
def to_h
|
|
24
28
|
{
|
|
25
29
|
kind: kind,
|
|
26
30
|
root: @root,
|
|
27
|
-
|
|
31
|
+
framework: framework,
|
|
32
|
+
test_patterns: test_patterns,
|
|
33
|
+
load_paths: load_paths,
|
|
28
34
|
environment: kind == "rails" ? RAILS_ENVIRONMENT.dup : {}
|
|
29
35
|
}
|
|
30
36
|
end
|
|
@@ -43,6 +49,47 @@ module Branchproof
|
|
|
43
49
|
File.file?(File.join(@root, "config", "environment.rb"))
|
|
44
50
|
end
|
|
45
51
|
|
|
52
|
+
def framework
|
|
53
|
+
@resolved_framework
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def resolve_framework!
|
|
57
|
+
rspec = rspec_markers?
|
|
58
|
+
minitest = minitest_markers?
|
|
59
|
+
if @framework == "auto"
|
|
60
|
+
raise_both_frameworks if rspec && minitest
|
|
61
|
+
@resolved_framework = rspec ? "rspec" : "minitest"
|
|
62
|
+
else
|
|
63
|
+
@resolved_framework = @framework
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def rspec_markers?
|
|
68
|
+
File.file?(File.join(@root, ".rspec")) || !Dir.glob(File.join(@root, "spec", "**", "*_spec.rb")).empty?
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def raise_both_frameworks
|
|
72
|
+
raise ArgumentError,
|
|
73
|
+
"project contains both RSpec and Minitest markers; specify --framework rspec or --framework minitest"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def minitest_markers?
|
|
77
|
+
!Dir.glob(File.join(@root, "test", "**", "*_test.rb")).empty? ||
|
|
78
|
+
!Dir.glob(File.join(@root, "test", "**", "test_*.rb")).empty?
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def test_patterns
|
|
82
|
+
if framework == "rspec"
|
|
83
|
+
%w[spec/**/*_spec.rb].freeze
|
|
84
|
+
else
|
|
85
|
+
%w[test/**/*_test.rb test/**/test_*.rb].freeze
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def load_paths
|
|
90
|
+
[File.join(@root, "lib"), File.join(@root, framework == "rspec" ? "spec" : "test")]
|
|
91
|
+
end
|
|
92
|
+
|
|
46
93
|
def validate_root!
|
|
47
94
|
raise ArgumentError, "project root does not exist: #{@root}" unless File.directory?(@root)
|
|
48
95
|
return unless @mode == "rails" && !rails_files?
|