branchproof 0.11.0 → 0.12.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 +89 -0
- data/README.md +440 -29
- data/doc/Branchproof/CLI.md +1 -1
- data/doc/Branchproof/ChangedCoverage.md +19 -0
- data/doc/Branchproof/ChangedCoveragePolicy.md +24 -0
- data/doc/Branchproof/ChangedDecisionMap.md +20 -0
- data/doc/Branchproof/ChangedScope.md +18 -0
- data/doc/Branchproof/Collation.md +23 -0
- data/doc/Branchproof/CollationManifest.md +29 -0
- data/doc/Branchproof/CoverageSummary.md +20 -0
- data/doc/Branchproof/Doctor/TerminalRenderer.md +15 -0
- data/doc/Branchproof/Doctor.md +34 -0
- data/doc/Branchproof/FallbackInstrumentation.md +8 -0
- data/doc/Branchproof/FallbackRuntime.md +12 -0
- data/doc/Branchproof/FallbackSyntax.md +13 -0
- data/doc/Branchproof/GitChanges.md +19 -0
- data/doc/Branchproof/GithubReport.md +25 -0
- data/doc/Branchproof/HtmlReport.md +22 -0
- data/doc/Branchproof/Instrumenter.md +1 -1
- data/doc/Branchproof/MinitestAdapter.md +4 -0
- data/doc/Branchproof/RakeTask.md +121 -0
- data/doc/Branchproof/Report.md +41 -3
- data/doc/Branchproof/ReportSelection.md +10 -1
- data/doc/Branchproof/Runtime.md +4 -1
- data/doc/Branchproof/Source.md +6 -1
- data/doc/Branchproof/SummaryRanking/Decision.md +86 -0
- data/doc/Branchproof/SummaryRanking/SourceFile.md +43 -0
- data/doc/Branchproof/SummaryRanking.md +36 -0
- data/doc/Branchproof/SummaryReport.md +42 -0
- data/doc/Branchproof/Worker.md +1 -1
- data/doc/Branchproof.md +21 -1
- data/doc/CHANGELOG.md +89 -0
- data/doc/README.md +440 -29
- data/lib/branchproof/analyzer.rb +2 -52
- data/lib/branchproof/changed_coverage.rb +177 -0
- data/lib/branchproof/changed_decision_map.rb +359 -0
- data/lib/branchproof/changed_scope.rb +93 -0
- data/lib/branchproof/cli.rb +242 -21
- data/lib/branchproof/collation.rb +525 -0
- data/lib/branchproof/collation_manifest.rb +129 -0
- data/lib/branchproof/comparison.rb +11 -3
- data/lib/branchproof/comparison_report.rb +2 -0
- data/lib/branchproof/configuration.rb +12 -2
- data/lib/branchproof/coverage_policy.rb +122 -0
- data/lib/branchproof/coverage_summary.rb +94 -0
- data/lib/branchproof/doctor.rb +241 -0
- data/lib/branchproof/evidence.rb +5 -1
- data/lib/branchproof/exception_instrumentation.rb +4 -3
- data/lib/branchproof/exception_syntax.rb +15 -1
- data/lib/branchproof/fallback_instrumentation.rb +25 -0
- data/lib/branchproof/fallback_runtime.rb +14 -0
- data/lib/branchproof/fallback_syntax.rb +90 -0
- data/lib/branchproof/focused_report.rb +81 -22
- data/lib/branchproof/git_changes.rb +188 -0
- data/lib/branchproof/github_report.rb +292 -0
- data/lib/branchproof/html_report.rb +412 -0
- data/lib/branchproof/instrumenter.rb +2 -0
- data/lib/branchproof/minimizer.rb +4 -2
- data/lib/branchproof/minitest_adapter.rb +45 -5
- data/lib/branchproof/rails_support.rb +2 -2
- data/lib/branchproof/rake_task.rb +186 -0
- data/lib/branchproof/report.rb +383 -71
- data/lib/branchproof/report_selection.rb +26 -5
- data/lib/branchproof/runtime.rb +2 -0
- data/lib/branchproof/saved_report.rb +323 -5
- data/lib/branchproof/source.rb +25 -4
- data/lib/branchproof/summary_ranking.rb +165 -0
- data/lib/branchproof/summary_report.rb +241 -0
- data/lib/branchproof/version.rb +1 -1
- data/lib/branchproof/worker.rb +14 -9
- data/lib/branchproof.rb +12 -0
- data/llms.txt +21 -1
- data/sig/branchproof.rbs +206 -5
- metadata +38 -22
data/lib/branchproof/report.rb
CHANGED
|
@@ -6,22 +6,33 @@ require "json"
|
|
|
6
6
|
require "pathname"
|
|
7
7
|
require_relative "coverage_policy"
|
|
8
8
|
require_relative "report_selection"
|
|
9
|
+
require_relative "summary_report"
|
|
10
|
+
require_relative "github_report"
|
|
11
|
+
require_relative "html_report"
|
|
12
|
+
require_relative "changed_coverage"
|
|
9
13
|
|
|
10
14
|
module Branchproof
|
|
11
15
|
# Renders versioned terminal and JSON analysis reports.
|
|
12
16
|
class Report
|
|
13
17
|
SCHEMA_VERSION = "1.4"
|
|
18
|
+
CHANGED_SCOPE_SCHEMA_VERSION = "1.5"
|
|
19
|
+
CHANGED_POLICY_SCHEMA_VERSION = "1.6"
|
|
20
|
+
COLLATION_SCHEMA_VERSION = "1.7"
|
|
14
21
|
CRITERION_VERSION = "masking_occurrence_v1"
|
|
15
|
-
VIEWS = %i[decisions conditions tests decision_tables].freeze
|
|
22
|
+
VIEWS = %i[decisions conditions tests decision_tables summary].freeze
|
|
23
|
+
FORMATS = %i[terminal json github html].freeze
|
|
16
24
|
DECISION_TABLE_LABELS = { "true" => "T", "false" => "F", "dont_care" => "-" }.freeze
|
|
17
25
|
DECISION_TABLE_STATUS_LABELS = { "covered" => "COVERED", "missing" => "MISSING",
|
|
18
26
|
"excluded" => "EXCLUDED" }.freeze
|
|
27
|
+
CRITERIA_LABELS = { decision: "Decision", condition: "Condition", condition_decision: "Condition/decision",
|
|
28
|
+
mcdc: "MC/DC", decision_table: "Decision table", alternative: "Alternative" }.freeze
|
|
19
29
|
|
|
20
30
|
def initialize(inventory:, evidence:, analysis:, minima:, baseline:, diagnostics:, level: 3, missing_only: false,
|
|
21
|
-
view: :decisions, run_metadata: {}, saved_document: nil, focus: nil, top: nil, minimum: nil
|
|
31
|
+
view: :decisions, run_metadata: {}, saved_document: nil, focus: nil, top: nil, minimum: nil,
|
|
32
|
+
changed_scope: nil, minimum_changed: nil)
|
|
22
33
|
raise ArgumentError, "level must be 1, 2, or 3" unless [1, 2, 3].include?(level.to_i)
|
|
23
34
|
unless VIEWS.include?(view.to_sym)
|
|
24
|
-
raise ArgumentError, "view must be :decisions, :conditions, :tests, or :
|
|
35
|
+
raise ArgumentError, "view must be :decisions, :conditions, :tests, :decision_tables, or :summary"
|
|
25
36
|
end
|
|
26
37
|
|
|
27
38
|
@inventory = inventory || {}
|
|
@@ -35,37 +46,65 @@ module Branchproof
|
|
|
35
46
|
@view = view.to_sym
|
|
36
47
|
@run_metadata = run_metadata || {}
|
|
37
48
|
@saved_document = saved_document
|
|
49
|
+
raise ArgumentError, "changed_scope must be a hash" if !changed_scope.nil? && !changed_scope.is_a?(Hash)
|
|
50
|
+
|
|
51
|
+
@changed_scope = changed_scope
|
|
38
52
|
raise ArgumentError, "minimum must be a hash" if !minimum.nil? && !minimum.is_a?(Hash)
|
|
39
53
|
|
|
40
54
|
@minimum_override = minimum.is_a?(Hash) && !minimum.empty? ? CoveragePolicy.normalize(minimum) : nil
|
|
41
55
|
@minimum = effective_minimum
|
|
42
|
-
|
|
56
|
+
raise ArgumentError, "minimum_changed must be a hash" if !minimum_changed.nil? && !minimum_changed.is_a?(Hash)
|
|
57
|
+
|
|
58
|
+
@minimum_changed_override = normalize_minimum_changed(minimum_changed)
|
|
59
|
+
@minimum_changed = effective_minimum_changed
|
|
60
|
+
if !@minimum_changed.empty? && !@changed_scope
|
|
61
|
+
raise ArgumentError, "minimum-changed requires a captured changed scope"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
@selection = ReportSelection.new(focus: focus, top: top,
|
|
65
|
+
decision_ids: value(@changed_scope, :decision_ids))
|
|
43
66
|
end
|
|
44
67
|
|
|
45
68
|
def self.from_document(document:, level: nil, view: :decisions, missing_only: false, focus: nil, top: nil,
|
|
46
|
-
minimum: nil)
|
|
69
|
+
minimum: nil, minimum_changed: nil)
|
|
47
70
|
data = document || {}
|
|
71
|
+
captured_scope = data[:changed_scope] || data["changed_scope"]
|
|
48
72
|
new(inventory: data[:source_inventory] || data["source_inventory"],
|
|
49
73
|
evidence: data[:observations] || data["observations"],
|
|
50
74
|
analysis: data[:analysis] || data["analysis"], minima: data[:minima] || data["minima"],
|
|
51
75
|
baseline: data[:baseline] || data["baseline"], diagnostics: data[:diagnostics] || data["diagnostics"],
|
|
52
76
|
level: level || (data[:analysis] || data["analysis"] ? 3 : 1), view: view, missing_only: missing_only,
|
|
53
77
|
focus: focus, top: top,
|
|
54
|
-
run_metadata: data[:run_metadata] || data["run_metadata"], saved_document: data, minimum: minimum
|
|
78
|
+
run_metadata: data[:run_metadata] || data["run_metadata"], saved_document: data, minimum: minimum,
|
|
79
|
+
minimum_changed: minimum_changed,
|
|
80
|
+
changed_scope: captured_scope)
|
|
55
81
|
end
|
|
56
82
|
|
|
57
|
-
|
|
83
|
+
# path_prefix is only used by :github, to make annotation paths repository-relative.
|
|
84
|
+
def write(io:, format:, path_prefix: nil)
|
|
58
85
|
format = format.to_sym
|
|
59
|
-
raise ArgumentError, "format must be :terminal or :
|
|
60
|
-
raise ArgumentError, "focus and top filters are terminal-only" if format == :json &&
|
|
61
|
-
|
|
62
|
-
|
|
86
|
+
raise ArgumentError, "format must be :terminal, :json, :github, or :html" unless FORMATS.include?(format)
|
|
87
|
+
raise ArgumentError, "focus and top filters are terminal-only" if format == :json &&
|
|
88
|
+
(@selection.focus_active? || @selection.top)
|
|
89
|
+
|
|
90
|
+
io.write(case format
|
|
91
|
+
when :json then JSON.generate(json_document)
|
|
92
|
+
when :github then github_report.annotations(path_prefix: path_prefix)
|
|
93
|
+
when :html then HtmlReport.new(document: json_document, level: @level, coordinator: self,
|
|
94
|
+
missing_only: @missing_only, selection: @selection).render
|
|
95
|
+
else terminal_document
|
|
96
|
+
end)
|
|
63
97
|
nil
|
|
64
98
|
end
|
|
65
99
|
|
|
100
|
+
# Markdown job summary for GitHub Actions ($GITHUB_STEP_SUMMARY).
|
|
101
|
+
def step_summary
|
|
102
|
+
github_report.step_summary
|
|
103
|
+
end
|
|
104
|
+
|
|
66
105
|
# Shares the existing missing-case wording with focused terminal views.
|
|
67
106
|
def condition_explanation(decision_id:, condition_id:)
|
|
68
|
-
decision =
|
|
107
|
+
decision = inventory_decisions_by_id[decision_id.to_s]
|
|
69
108
|
return "" unless decision
|
|
70
109
|
|
|
71
110
|
condition = Array(value(decision, :conditions)).find { |item| value(item, :id).to_s == condition_id.to_s }
|
|
@@ -90,7 +129,7 @@ module Branchproof
|
|
|
90
129
|
# Returns condition-value evidence for focused renderers without exposing
|
|
91
130
|
# the report's internal document traversal or mutating saved records.
|
|
92
131
|
def condition_coverage_evidence(decision_id:, condition_id:)
|
|
93
|
-
decision =
|
|
132
|
+
decision = inventory_decisions_by_id[decision_id.to_s]
|
|
94
133
|
condition = Array(value(decision, :conditions)).find do |item|
|
|
95
134
|
value(item, :id).to_s == condition_id.to_s
|
|
96
135
|
end
|
|
@@ -112,12 +151,108 @@ module Branchproof
|
|
|
112
151
|
lines
|
|
113
152
|
end
|
|
114
153
|
|
|
154
|
+
# Shared informational changed-scope header for all terminal and GitHub views.
|
|
155
|
+
def changed_scope_lines
|
|
156
|
+
return [] unless @changed_scope
|
|
157
|
+
|
|
158
|
+
files = value(@changed_scope, :files)
|
|
159
|
+
deleted_paths = Array(files).filter_map do |file|
|
|
160
|
+
scope_text(value(file, :path)) if value(file, :status).to_s == "D"
|
|
161
|
+
end
|
|
162
|
+
ref = scope_text(value(@changed_scope, :requested_ref))
|
|
163
|
+
base = scope_text(value(@changed_scope, :base_commit))
|
|
164
|
+
lines = ["Changed scope: #{ref} (resolved #{base})",
|
|
165
|
+
"Comparison: tracked worktree; untracked files excluded"]
|
|
166
|
+
unless deleted_paths.empty?
|
|
167
|
+
lines << "Deleted files: #{deleted_paths.join(", ")} (no current decisions to display)"
|
|
168
|
+
end
|
|
169
|
+
header = @minimum_changed.empty? ? "Changed coverage (informational):" : "Changed coverage:"
|
|
170
|
+
lines << header
|
|
171
|
+
coverage = value(json_document, :changed_coverage) || {}
|
|
172
|
+
overall_unavailable = value(coverage, :reason)
|
|
173
|
+
if overall_unavailable && overall_unavailable.to_s != "zero_denominator"
|
|
174
|
+
lines << " Changed coverage unavailable: #{changed_coverage_reason(coverage)}"
|
|
175
|
+
end
|
|
176
|
+
CRITERIA_LABELS.each do |criterion, label|
|
|
177
|
+
row = value(value(coverage, :coverage), criterion) || {}
|
|
178
|
+
denominator = value(row, :denominator)
|
|
179
|
+
percentage = value(row, :percentage)
|
|
180
|
+
lines << if value(row, :status).to_s == "available"
|
|
181
|
+
" #{label}: #{value(row, :numerator)}/#{denominator} (#{percentage}%)"
|
|
182
|
+
elsif value(row, :reason).to_s == "zero_denominator"
|
|
183
|
+
" #{label}: N/A (not applicable)"
|
|
184
|
+
else
|
|
185
|
+
" #{label}: unavailable (#{changed_coverage_reason(row)})"
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
lines.concat(changed_coverage_policy_lines)
|
|
189
|
+
lines
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Provenance summary for offline reports assembled from multiple shards.
|
|
193
|
+
def collation_lines
|
|
194
|
+
collation = value(@saved_document, :collation)
|
|
195
|
+
return [] unless collation.is_a?(Hash)
|
|
196
|
+
|
|
197
|
+
shards = Array(value(collation, :shards))
|
|
198
|
+
expected = value(collation, :expected_shards)
|
|
199
|
+
count = if expected.is_a?(Array)
|
|
200
|
+
"#{shards.length}/#{expected.length} expected"
|
|
201
|
+
else
|
|
202
|
+
"#{shards.length} (expected count unknown)"
|
|
203
|
+
end
|
|
204
|
+
missing = Array(value(collation, :missing_shards)).map { |id| scope_text(id) }
|
|
205
|
+
lines = ["Collation: #{scope_text(value(collation, :collection_status))}",
|
|
206
|
+
"Shards supplied: #{count}",
|
|
207
|
+
"Missing shards: #{missing.empty? ? "none" : missing.join(", ")}",
|
|
208
|
+
"Contributing shards:"]
|
|
209
|
+
shards.each do |shard|
|
|
210
|
+
id = scope_text(value(shard, :id))
|
|
211
|
+
paths = Array(value(shard, :paths)).map { |path| scope_text(path) }
|
|
212
|
+
lines << " #{id}: #{paths.empty? ? "path unavailable" : paths.join(", ")}"
|
|
213
|
+
end
|
|
214
|
+
lines
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def changed_coverage_policy_lines
|
|
218
|
+
policy = changed_coverage_policy
|
|
219
|
+
return [] if (value(policy, :minimum_changed) || {}).empty?
|
|
220
|
+
|
|
221
|
+
lines = ["Changed coverage policy: #{value(policy, :status).to_s.upcase}"]
|
|
222
|
+
Array(value(policy, :gates)).each do |gate|
|
|
223
|
+
numerator = value(gate, :numerator)
|
|
224
|
+
denominator = value(gate, :denominator)
|
|
225
|
+
count = denominator.nil? || denominator.zero? ? "N/A" : "#{numerator || "N/A"}/#{denominator}"
|
|
226
|
+
suffix = value(gate, :reason) ? "; reason: #{value(gate, :reason)}" : ""
|
|
227
|
+
lines << " #{value(gate, :criterion)}: #{count}, threshold #{value(gate, :minimum)}, " \
|
|
228
|
+
"#{value(gate, :status).to_s.upcase}#{suffix}"
|
|
229
|
+
end
|
|
230
|
+
lines
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def changed_coverage_available?
|
|
234
|
+
return true unless @changed_scope
|
|
235
|
+
|
|
236
|
+
coverage = value(json_document, :changed_coverage)
|
|
237
|
+
return false unless value(coverage, :status).to_s == "available"
|
|
238
|
+
|
|
239
|
+
rows = value(coverage, :coverage) || {}
|
|
240
|
+
rows.values.all? do |row|
|
|
241
|
+
value(row, :status).to_s == "available" || value(row, :reason).to_s == "zero_denominator"
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def display_scope_path(path)
|
|
246
|
+
@changed_scope ? scope_text(path) : path
|
|
247
|
+
end
|
|
248
|
+
|
|
115
249
|
def exit_code
|
|
116
250
|
return 2 unless usage_valid?
|
|
117
251
|
|
|
118
252
|
policy_status = value(coverage_policy, :status).to_s
|
|
119
|
-
|
|
120
|
-
return
|
|
253
|
+
changed_policy_status = value(changed_coverage_policy, :status).to_s
|
|
254
|
+
return 2 if [policy_status, changed_policy_status].include?("unavailable")
|
|
255
|
+
return 1 if [policy_status, changed_policy_status].include?("failed")
|
|
121
256
|
|
|
122
257
|
status = value(@baseline, :status).to_s.upcase
|
|
123
258
|
return 2 if %w[ERROR INCOMPLETE].include?(status)
|
|
@@ -148,20 +283,51 @@ module Branchproof
|
|
|
148
283
|
if @saved_document
|
|
149
284
|
document = normalize(@saved_document)
|
|
150
285
|
document["coverage_policy"] = normalize(coverage_policy) if @minimum_override
|
|
286
|
+
if @minimum_changed_override
|
|
287
|
+
document["schema_version"] = if document["schema_version"] == COLLATION_SCHEMA_VERSION
|
|
288
|
+
COLLATION_SCHEMA_VERSION
|
|
289
|
+
else
|
|
290
|
+
CHANGED_POLICY_SCHEMA_VERSION
|
|
291
|
+
end
|
|
292
|
+
document["changed_coverage_policy"] = normalize(changed_coverage_policy)
|
|
293
|
+
end
|
|
151
294
|
return document
|
|
152
295
|
end
|
|
153
296
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
297
|
+
schema_version = if !@minimum_changed.empty?
|
|
298
|
+
CHANGED_POLICY_SCHEMA_VERSION
|
|
299
|
+
elsif @changed_scope
|
|
300
|
+
CHANGED_SCOPE_SCHEMA_VERSION
|
|
301
|
+
else
|
|
302
|
+
SCHEMA_VERSION
|
|
303
|
+
end
|
|
304
|
+
document = normalize(schema_version: schema_version,
|
|
305
|
+
tool_version: (defined?(Branchproof::VERSION) ? Branchproof::VERSION : "unknown"),
|
|
306
|
+
criterion_version: CRITERION_VERSION, runtime: RUBY_DESCRIPTION,
|
|
307
|
+
run_ids: Array(value(@evidence, :run_ids)),
|
|
308
|
+
source_inventory: inventory_with_default_kinds, baseline: @baseline, observations: @evidence,
|
|
309
|
+
analysis: @analysis, minima: @minima, metrics: metrics,
|
|
310
|
+
diagnostics: @diagnostics, completeness: completeness, coverage_policy: coverage_policy,
|
|
311
|
+
run_metadata: @run_metadata)
|
|
312
|
+
if @changed_scope
|
|
313
|
+
document["changed_scope"] = normalize(@changed_scope)
|
|
314
|
+
document["changed_coverage"] = normalize(ChangedCoverage.call(document: document,
|
|
315
|
+
scope: @changed_scope))
|
|
316
|
+
document["changed_coverage_policy"] = normalize(changed_coverage_policy) unless @minimum_changed.empty?
|
|
317
|
+
end
|
|
318
|
+
document
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def github_report
|
|
322
|
+
@github_report ||= GithubReport.new(document: json_document, level: @level, coordinator: self,
|
|
323
|
+
selection: @selection)
|
|
162
324
|
end
|
|
163
325
|
|
|
164
326
|
def terminal_document
|
|
327
|
+
if @view == :summary
|
|
328
|
+
return SummaryReport.new(document: json_document, level: @level, coordinator: self,
|
|
329
|
+
missing_only: @missing_only, selection: @selection).render
|
|
330
|
+
end
|
|
165
331
|
unless @view == :decisions
|
|
166
332
|
return FocusedReport.new(document: json_document, view: @view, level: @level,
|
|
167
333
|
missing_only: @missing_only, coordinator: self,
|
|
@@ -179,8 +345,12 @@ module Branchproof
|
|
|
179
345
|
"Observations: #{metrics[:completed]} completed, #{metrics[:aborted]} aborted, " \
|
|
180
346
|
"#{metrics[:unattributed]} unattributed",
|
|
181
347
|
values_legend]
|
|
348
|
+
lines.insert(1, "Whole-run metrics:") if @changed_scope
|
|
349
|
+
lines << "Whole-run coverage and policy:" if @changed_scope
|
|
182
350
|
lines.concat(coverage_ladder_lines)
|
|
183
351
|
lines.concat(coverage_policy_lines)
|
|
352
|
+
lines.concat(changed_scope_lines)
|
|
353
|
+
lines.concat(collation_lines)
|
|
184
354
|
lines << missing_summary_line if @missing_only
|
|
185
355
|
selected_decisions, hidden = selected_decisions_for_display
|
|
186
356
|
append_selection_lines(lines, hidden, "decision")
|
|
@@ -189,7 +359,7 @@ module Branchproof
|
|
|
189
359
|
selected_decisions.each { |decision| render_decision(lines, decision) }
|
|
190
360
|
render_minima(lines) unless @missing_only
|
|
191
361
|
unless @diagnostics.empty?
|
|
192
|
-
lines << "Diagnostics:"
|
|
362
|
+
lines << (@changed_scope ? "Run-wide diagnostics:" : "Diagnostics:")
|
|
193
363
|
@diagnostics.each { |diagnostic| lines << " - #{diagnostic_message(diagnostic)}" }
|
|
194
364
|
end
|
|
195
365
|
lines.join("\n") << "\n"
|
|
@@ -198,6 +368,7 @@ module Branchproof
|
|
|
198
368
|
def render_decision(lines, decision)
|
|
199
369
|
source = source_for(decision)
|
|
200
370
|
filename = value(source, :relative_path) || value(source, :absolute_path) || value(decision, :source_id)
|
|
371
|
+
filename = display_scope_path(filename)
|
|
201
372
|
decision_label = "Decision #{short_id(value(decision, :id))} #{filename}:#{value(decision, :line)}"
|
|
202
373
|
lines << decision_label
|
|
203
374
|
lines << " Decision: #{value(decision, :expression)}"
|
|
@@ -528,7 +699,7 @@ module Branchproof
|
|
|
528
699
|
" #{label} (#{value(minimum, :status)}, decisions: [#{scope}]): #{selected}"
|
|
529
700
|
end.uniq
|
|
530
701
|
unless rows.empty?
|
|
531
|
-
lines << "Supporting sets:"
|
|
702
|
+
lines << (@changed_scope ? "Whole-run supporting sets:" : "Supporting sets:")
|
|
532
703
|
lines.concat(rows)
|
|
533
704
|
end
|
|
534
705
|
lines << "Additional tests outside this MC/DC evidence set may improve coverage." unless @minima.empty?
|
|
@@ -710,7 +881,7 @@ module Branchproof
|
|
|
710
881
|
end
|
|
711
882
|
end
|
|
712
883
|
|
|
713
|
-
def condition_detail(decision,
|
|
884
|
+
def condition_detail(decision, condition, result)
|
|
714
885
|
return "" unless result && (@level == 3 || @missing_only)
|
|
715
886
|
|
|
716
887
|
pair = value(result, :canonical_pair)
|
|
@@ -725,15 +896,15 @@ module Branchproof
|
|
|
725
896
|
candidates = Array(value(constraint, :candidate_vectors))
|
|
726
897
|
existing = existing_vector_for(constraint)
|
|
727
898
|
unless candidates.empty?
|
|
728
|
-
detail_lines = [
|
|
899
|
+
detail_lines = []
|
|
900
|
+
status = value(constraint, :status).to_s
|
|
901
|
+
if !status.empty? && status != "CANDIDATE"
|
|
902
|
+
detail_lines << " — Constraint status: #{status}; candidate suggestions follow"
|
|
903
|
+
end
|
|
729
904
|
candidates.each do |candidate|
|
|
730
|
-
detail_lines
|
|
731
|
-
detail_lines.concat(candidate_requirements(decision, candidate).map { |item| " #{item}" })
|
|
732
|
-
outcome = value(candidate, :outcome) ? "true" : "false"
|
|
733
|
-
detail_lines << " Expected decision: #{outcome} [#{vector_values(candidate)}]"
|
|
905
|
+
detail_lines.concat(missing_evidence_lines(decision, condition, candidate, existing))
|
|
734
906
|
end
|
|
735
907
|
detail_lines << " #{statement}" unless statement.to_s.empty?
|
|
736
|
-
detail_lines << " Compare with: #{existing_vector_context(existing)}"
|
|
737
908
|
return detail_lines.join("\n")
|
|
738
909
|
end
|
|
739
910
|
unless existing.nil?
|
|
@@ -749,6 +920,95 @@ module Branchproof
|
|
|
749
920
|
""
|
|
750
921
|
end
|
|
751
922
|
|
|
923
|
+
def missing_evidence_lines(decision, condition, candidate, existing)
|
|
924
|
+
target_index = value(condition, :index).to_i
|
|
925
|
+
target_expression = value(condition, :expression) || "condition #{target_index}"
|
|
926
|
+
lines = [" — Missing evidence for: #{target_expression}"]
|
|
927
|
+
unless existing
|
|
928
|
+
lines << " Candidate requirements (not observed):"
|
|
929
|
+
lines << " Need an observation where:"
|
|
930
|
+
Array(value(candidate, :values)).each_with_index do |required, index|
|
|
931
|
+
expression = condition_expression(decision, index) || "condition #{index}"
|
|
932
|
+
requirement = if required.nil?
|
|
933
|
+
"is not evaluated (short-circuited)"
|
|
934
|
+
else
|
|
935
|
+
"is #{required ? "truthy" : "falsey"}"
|
|
936
|
+
end
|
|
937
|
+
lines << " #{expression} #{requirement}"
|
|
938
|
+
end
|
|
939
|
+
expected_outcome = value(candidate, :outcome)
|
|
940
|
+
unless expected_outcome.nil?
|
|
941
|
+
lines << " Expected decision: #{boolean_word(expected_outcome)} [#{vector_values(candidate)}]"
|
|
942
|
+
end
|
|
943
|
+
lines << " Candidate combination (not observed): #{candidate_values_text(decision, candidate)}"
|
|
944
|
+
return lines
|
|
945
|
+
end
|
|
946
|
+
|
|
947
|
+
existing_values = Array(value(existing, :values))
|
|
948
|
+
candidate_values = Array(value(candidate, :values))
|
|
949
|
+
observed_target = existing_values[target_index]
|
|
950
|
+
candidate_target = candidate_values[target_index]
|
|
951
|
+
lines << " Observed decision expression: #{value(decision, :expression)}"
|
|
952
|
+
lines << " Find two executions where:"
|
|
953
|
+
target_requirement = if observed_target.nil? || candidate_target.nil?
|
|
954
|
+
"#{target_expression} is not evaluated in one execution (short-circuited); " \
|
|
955
|
+
"no Boolean flip can be claimed"
|
|
956
|
+
else
|
|
957
|
+
"#{target_expression} changes between #{boolean_word(observed_target)} and " \
|
|
958
|
+
"#{boolean_word(candidate_target)}"
|
|
959
|
+
end
|
|
960
|
+
lines << " #{target_requirement}"
|
|
961
|
+
Array(value(decision, :conditions)).each_with_index do |item, index|
|
|
962
|
+
next if index == target_index
|
|
963
|
+
|
|
964
|
+
before = existing_values[index]
|
|
965
|
+
after = candidate_values[index]
|
|
966
|
+
expression = value(item, :expression) || "condition #{index}"
|
|
967
|
+
if !before.nil? && before == after
|
|
968
|
+
lines << " #{expression} remains #{boolean_word(before)}"
|
|
969
|
+
elsif !before.nil? && !after.nil? && before != after
|
|
970
|
+
lines << " #{expression} also changes from #{boolean_word(before)} to #{boolean_word(after)}"
|
|
971
|
+
elsif before.nil? || after.nil?
|
|
972
|
+
lines << " #{expression} is not evaluated (short-circuited) in one execution"
|
|
973
|
+
end
|
|
974
|
+
end
|
|
975
|
+
before_outcome = value(existing, :outcome)
|
|
976
|
+
after_outcome = value(candidate, :outcome)
|
|
977
|
+
outcome_requirement = if before_outcome.nil? || after_outcome.nil?
|
|
978
|
+
"decision outcome is unavailable for one execution"
|
|
979
|
+
elsif before_outcome != after_outcome
|
|
980
|
+
"decision outcome changes between #{boolean_word(before_outcome)} and " \
|
|
981
|
+
"#{boolean_word(after_outcome)}"
|
|
982
|
+
else
|
|
983
|
+
"decision outcome remains #{boolean_word(before_outcome)}"
|
|
984
|
+
end
|
|
985
|
+
lines << " #{outcome_requirement}"
|
|
986
|
+
lines << " Observed: #{candidate_values_text(decision, existing)}#{existing_owner_suffix(existing)}"
|
|
987
|
+
lines << " Missing counterpart: #{candidate_values_text(decision, candidate)} (candidate; not observed)"
|
|
988
|
+
lines
|
|
989
|
+
end
|
|
990
|
+
|
|
991
|
+
def existing_owner_suffix(existing)
|
|
992
|
+
owner_ids = Array(value(existing, :test_ids))
|
|
993
|
+
owners = owner_ids.first(2).map { |id| test_label(id) }
|
|
994
|
+
owners << "#{owner_ids.length - 2} more" if owner_ids.length > 2
|
|
995
|
+
owners.empty? ? "" : " (#{owners.join(", ")})"
|
|
996
|
+
end
|
|
997
|
+
|
|
998
|
+
def candidate_values_text(decision, vector)
|
|
999
|
+
values = Array(value(vector, :values)).each_with_index.map do |item, index|
|
|
1000
|
+
expression = condition_expression(decision, index) || "condition #{index}"
|
|
1001
|
+
"#{expression} = #{item.nil? ? "not evaluated (short-circuited)" : boolean_word(item)}"
|
|
1002
|
+
end
|
|
1003
|
+
outcome = value(vector, :outcome)
|
|
1004
|
+
values << "decision = #{outcome.nil? ? "unavailable" : boolean_word(outcome)}"
|
|
1005
|
+
values.join("; ")
|
|
1006
|
+
end
|
|
1007
|
+
|
|
1008
|
+
def boolean_word(value)
|
|
1009
|
+
value ? "true" : "false"
|
|
1010
|
+
end
|
|
1011
|
+
|
|
752
1012
|
def readable_constraints(decision, constraint)
|
|
753
1013
|
Array(value(constraint, :constraints)).filter_map do |item|
|
|
754
1014
|
if item.respond_to?(:key?)
|
|
@@ -768,17 +1028,6 @@ module Branchproof
|
|
|
768
1028
|
value(condition, :expression)
|
|
769
1029
|
end
|
|
770
1030
|
|
|
771
|
-
def candidate_requirements(decision, candidate)
|
|
772
|
-
Array(value(candidate, :values)).each_with_index.map do |required, index|
|
|
773
|
-
expression = condition_expression(decision, index) || "condition #{index}"
|
|
774
|
-
if required.nil?
|
|
775
|
-
"#{expression} is not evaluated (short-circuited)"
|
|
776
|
-
else
|
|
777
|
-
"#{expression} is #{required ? "truthy" : "falsey"}"
|
|
778
|
-
end
|
|
779
|
-
end
|
|
780
|
-
end
|
|
781
|
-
|
|
782
1031
|
def vector_values(vector)
|
|
783
1032
|
Array(value(vector, :values)).map do |item|
|
|
784
1033
|
if item.nil?
|
|
@@ -791,16 +1040,6 @@ module Branchproof
|
|
|
791
1040
|
end.join
|
|
792
1041
|
end
|
|
793
1042
|
|
|
794
|
-
def existing_vector_context(existing)
|
|
795
|
-
return "no existing effective observation" unless existing
|
|
796
|
-
|
|
797
|
-
owner_ids = Array(value(existing, :test_ids))
|
|
798
|
-
owners = owner_ids.first(2).map { |id| test_label(id) }
|
|
799
|
-
owners << "#{owner_ids.length - 2} more" if owner_ids.length > 2
|
|
800
|
-
context = "[#{vector_values(existing)}] => #{value(existing, :outcome) ? "true" : "false"}"
|
|
801
|
-
owners.empty? ? context : "#{context} (#{owners.join(", ")})"
|
|
802
|
-
end
|
|
803
|
-
|
|
804
1043
|
def existing_vector_for(constraint)
|
|
805
1044
|
id = value(constraint, :existing_vector_id).to_s
|
|
806
1045
|
return if id.empty?
|
|
@@ -937,19 +1176,26 @@ module Branchproof
|
|
|
937
1176
|
end
|
|
938
1177
|
|
|
939
1178
|
def decisions_to_render
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
1179
|
+
decisions = if !@missing_only
|
|
1180
|
+
inventory_decisions
|
|
1181
|
+
elsif !analysis_available?
|
|
1182
|
+
[]
|
|
1183
|
+
else
|
|
1184
|
+
inventory_decisions
|
|
1185
|
+
.reject { |decision| unsupported?(decision) }
|
|
1186
|
+
.select do |decision|
|
|
1187
|
+
if nonboolean_decision?(decision)
|
|
1188
|
+
missing_alternatives_for(decision).any?
|
|
1189
|
+
else
|
|
1190
|
+
conditions_to_render(decision).any? || missing_decision_table_rules(decision).any? ||
|
|
1191
|
+
decision_table_unavailable?(decision)
|
|
1192
|
+
end
|
|
1193
|
+
end
|
|
1194
|
+
end
|
|
1195
|
+
return decisions unless @selection.decision_ids
|
|
1196
|
+
|
|
1197
|
+
inventory = value(@inventory, :source_units) ? @inventory : { source_units: [], decisions: inventory_decisions }
|
|
1198
|
+
@selection.filter_decisions(decisions, inventory: inventory)
|
|
953
1199
|
end
|
|
954
1200
|
|
|
955
1201
|
def selected_decisions_for_display
|
|
@@ -964,11 +1210,17 @@ module Branchproof
|
|
|
964
1210
|
|
|
965
1211
|
def append_selection_lines(lines, hidden, unit)
|
|
966
1212
|
if @selection.focus_active?
|
|
967
|
-
matching = @
|
|
1213
|
+
matching = if @changed_scope
|
|
1214
|
+
@selection.selected_decision_ids(json_document)
|
|
1215
|
+
else
|
|
1216
|
+
@selection.matching_decision_ids(json_document)
|
|
1217
|
+
end
|
|
1218
|
+
focus_label = display_scope_path(@selection.focus_label)
|
|
1219
|
+
no_match = @changed_scope ? "no matching changed decisions" : "no matching decisions"
|
|
968
1220
|
lines << if matching.empty?
|
|
969
|
-
"Focus:
|
|
1221
|
+
"Focus: #{no_match} for #{focus_label}"
|
|
970
1222
|
else
|
|
971
|
-
"Focus: #{
|
|
1223
|
+
"Focus: #{focus_label}"
|
|
972
1224
|
end
|
|
973
1225
|
end
|
|
974
1226
|
return unless @selection.top
|
|
@@ -1049,6 +1301,32 @@ module Branchproof
|
|
|
1049
1301
|
base.merge(@minimum_override || {})
|
|
1050
1302
|
end
|
|
1051
1303
|
|
|
1304
|
+
def normalize_minimum_changed(minimum)
|
|
1305
|
+
return nil if minimum.nil? || minimum.empty?
|
|
1306
|
+
|
|
1307
|
+
CoveragePolicy.normalize(minimum)
|
|
1308
|
+
end
|
|
1309
|
+
|
|
1310
|
+
def effective_minimum_changed
|
|
1311
|
+
inherited = value(value(@saved_document, :changed_coverage_policy), :minimum_changed)
|
|
1312
|
+
base = inherited.is_a?(Hash) ? normalize_minimum_changed(inherited) : {}
|
|
1313
|
+
base.merge(@minimum_changed_override || {})
|
|
1314
|
+
end
|
|
1315
|
+
|
|
1316
|
+
def changed_coverage_policy
|
|
1317
|
+
@changed_coverage_policy ||= ChangedCoveragePolicy.new(minimum_changed: @minimum_changed).call(
|
|
1318
|
+
document: policy_document, scope: @changed_scope
|
|
1319
|
+
)
|
|
1320
|
+
end
|
|
1321
|
+
|
|
1322
|
+
def changed_coverage_summary
|
|
1323
|
+
@changed_coverage_summary ||= if @saved_document
|
|
1324
|
+
value(@saved_document, :changed_coverage)
|
|
1325
|
+
else
|
|
1326
|
+
ChangedCoverage.call(document: policy_document, scope: @changed_scope)
|
|
1327
|
+
end
|
|
1328
|
+
end
|
|
1329
|
+
|
|
1052
1330
|
def coverage_available?
|
|
1053
1331
|
analysis_available? && !value(@analysis, :coverage).nil?
|
|
1054
1332
|
end
|
|
@@ -1148,6 +1426,10 @@ module Branchproof
|
|
|
1148
1426
|
def missing_summary_line
|
|
1149
1427
|
return "Missing conditions: Cannot identify missing conditions (analysis unavailable)" unless analysis_available?
|
|
1150
1428
|
return "Missing conditions: Cannot identify missing conditions (analysis incomplete)" unless analysis_complete?
|
|
1429
|
+
if @changed_scope && (!changed_coverage_available? ||
|
|
1430
|
+
(@selection.focus_active? && @selection.selected_decision_ids(json_document).empty?))
|
|
1431
|
+
return "Changed-scope missing coverage unavailable from the captured evidence"
|
|
1432
|
+
end
|
|
1151
1433
|
|
|
1152
1434
|
unavailable = unavailable_decision_table_count
|
|
1153
1435
|
unavailable_suffix = if unavailable.positive?
|
|
@@ -1208,6 +1490,13 @@ module Branchproof
|
|
|
1208
1490
|
@inventory_decisions ||= Array(value(@inventory, :decisions)).freeze
|
|
1209
1491
|
end
|
|
1210
1492
|
|
|
1493
|
+
def inventory_decisions_by_id
|
|
1494
|
+
@inventory_decisions_by_id ||= inventory_decisions.each_with_object({}) do |decision, index|
|
|
1495
|
+
id = value(decision, :id).to_s
|
|
1496
|
+
index[id] = decision unless index.key?(id)
|
|
1497
|
+
end
|
|
1498
|
+
end
|
|
1499
|
+
|
|
1211
1500
|
def vectors
|
|
1212
1501
|
@vectors ||= Array(value(@evidence, :vectors)).freeze
|
|
1213
1502
|
end
|
|
@@ -1257,8 +1546,31 @@ module Branchproof
|
|
|
1257
1546
|
def normalize_unknown(object)
|
|
1258
1547
|
object.to_s
|
|
1259
1548
|
end
|
|
1260
|
-
|
|
1261
|
-
|
|
1549
|
+
|
|
1550
|
+
def changed_coverage_reason(row)
|
|
1551
|
+
reason = value(row, :reason)
|
|
1552
|
+
{
|
|
1553
|
+
"empty_scope" => "no decisions matched the changed files",
|
|
1554
|
+
"unsupported_only" => "all changed decisions are unsupported",
|
|
1555
|
+
"baseline_unavailable" => "the test run is incomplete",
|
|
1556
|
+
"incomplete_document" => "the report is incomplete",
|
|
1557
|
+
"incomplete_observations" => "observation evidence is incomplete",
|
|
1558
|
+
"incomplete_analysis" => "analysis evidence is incomplete",
|
|
1559
|
+
"analysis_unavailable" => "analysis is unavailable",
|
|
1560
|
+
"missing_selected_analysis" => "analysis is missing for changed decisions",
|
|
1561
|
+
"missing_selected_coverage" => "coverage is missing for changed decisions",
|
|
1562
|
+
"decision_table_not_calculated" => "decision table analysis was not calculated",
|
|
1563
|
+
"zero_denominator" => "not applicable"
|
|
1564
|
+
}.fetch(reason.to_s, "the captured evidence is unavailable")
|
|
1565
|
+
end
|
|
1566
|
+
|
|
1567
|
+
def scope_text(text)
|
|
1568
|
+
text.to_s.scrub.gsub(/[[:cntrl:]]/) { |character| "\\u{#{character.ord.to_s(16).upcase}}" }
|
|
1569
|
+
end
|
|
1570
|
+
public :condition_coverage_evidence, :changed_scope_lines, :collation_lines, :coverage_ladder_lines,
|
|
1571
|
+
:coverage_policy_lines, :coverage_policy, :changed_coverage_policy, :coverage_status_label,
|
|
1572
|
+
:changed_coverage_available?,
|
|
1573
|
+
:display_scope_path,
|
|
1262
1574
|
:decision_table_requirement, :decision_table_reachability, :decision_table_expected_heading
|
|
1263
1575
|
end
|
|
1264
1576
|
end
|