branchproof 0.11.1 → 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 +82 -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 +1 -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 +82 -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 +15 -3
- 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 +39 -23
|
@@ -8,7 +8,7 @@ module Branchproof
|
|
|
8
8
|
# Compares two complete report documents without loading or executing the project.
|
|
9
9
|
class Comparison
|
|
10
10
|
SCHEMA_VERSION = "1.0"
|
|
11
|
-
SUPPORTED_REPORT_SCHEMAS = %w[1.0 1.1 1.2 1.3 1.4].freeze
|
|
11
|
+
SUPPORTED_REPORT_SCHEMAS = %w[1.0 1.1 1.2 1.3 1.4 1.5 1.6 1.7].freeze
|
|
12
12
|
CRITERION_VERSION = "masking_occurrence_v1"
|
|
13
13
|
|
|
14
14
|
def initialize(before:, after:)
|
|
@@ -58,7 +58,7 @@ module Branchproof
|
|
|
58
58
|
status = reasons.empty? ? "complete" : "comparison incomplete"
|
|
59
59
|
lost = changes.count { |change| change["change"] == "lost proof" }
|
|
60
60
|
|
|
61
|
-
{
|
|
61
|
+
result = {
|
|
62
62
|
"schema_version" => SCHEMA_VERSION,
|
|
63
63
|
"criterion_version" => CRITERION_VERSION,
|
|
64
64
|
"status" => status,
|
|
@@ -87,8 +87,16 @@ module Branchproof
|
|
|
87
87
|
"decision_table_regressions" => table_changes.count { |change| change["change"] == "rule coverage lost" },
|
|
88
88
|
"regressions" => lost,
|
|
89
89
|
"regression" => (lost.positive? || table_changes.any? { |change| change["change"] == "rule coverage lost" }) &&
|
|
90
|
-
|
|
90
|
+
status == "complete"
|
|
91
91
|
}
|
|
92
|
+
if value(@before, :changed_scope) || value(@after, :changed_scope)
|
|
93
|
+
result["changed_scope"] = {
|
|
94
|
+
"before" => value(@before, :changed_scope),
|
|
95
|
+
"after" => value(@after, :changed_scope),
|
|
96
|
+
"notice" => "Changed scope is informational; comparison uses whole-run evidence."
|
|
97
|
+
}
|
|
98
|
+
end
|
|
99
|
+
result
|
|
92
100
|
end
|
|
93
101
|
|
|
94
102
|
private
|
|
@@ -34,6 +34,8 @@ module Branchproof
|
|
|
34
34
|
|
|
35
35
|
def terminal_document
|
|
36
36
|
lines = ["Branchproof comparison", "Status: #{status}"]
|
|
37
|
+
changed_scope = value(:changed_scope)
|
|
38
|
+
lines << value_from(changed_scope, :notice).to_s if changed_scope
|
|
37
39
|
reasons.each { |reason| lines << "Reason: #{reason}" }
|
|
38
40
|
Array(value(:context)).each { |item| lines << "Context: #{item}" }
|
|
39
41
|
lines << "Conditions: #{matching.fetch("matched_conditions",
|
|
@@ -10,7 +10,7 @@ module Branchproof
|
|
|
10
10
|
PROJECTS = %w[auto ruby rails].freeze
|
|
11
11
|
FRAMEWORKS = %w[auto minitest rspec].freeze
|
|
12
12
|
MINIMUM_CRITERIA = CoveragePolicy::CRITERIA.keys.freeze
|
|
13
|
-
FIELDS = %w[schema_version project framework sources tests exclude minimum].freeze
|
|
13
|
+
FIELDS = %w[schema_version project framework sources tests exclude minimum minimum_changed].freeze
|
|
14
14
|
|
|
15
15
|
class << self
|
|
16
16
|
def load(path:, root:, explicit: false, disabled: false)
|
|
@@ -52,7 +52,7 @@ module Branchproof
|
|
|
52
52
|
payload.each_with_object({ schema_version: SCHEMA_VERSION, path: path, root: root }) do |(key, value), result|
|
|
53
53
|
next if key == "schema_version"
|
|
54
54
|
|
|
55
|
-
result[key.to_sym] =
|
|
55
|
+
result[key.to_sym] = %w[minimum minimum_changed].include?(key) ? value.transform_keys(&:to_s) : value
|
|
56
56
|
end
|
|
57
57
|
end
|
|
58
58
|
|
|
@@ -77,6 +77,7 @@ module Branchproof
|
|
|
77
77
|
%w[sources tests].each { |field| validate_nonempty_strings(payload, field) if payload.key?(field) }
|
|
78
78
|
validate_strings(payload, "exclude") if payload.key?("exclude")
|
|
79
79
|
validate_minimum(payload["minimum"]) if payload.key?("minimum")
|
|
80
|
+
validate_minimum_changed(payload["minimum_changed"]) if payload.key?("minimum_changed")
|
|
80
81
|
end
|
|
81
82
|
|
|
82
83
|
def validate_enum(payload, field, values)
|
|
@@ -106,6 +107,15 @@ module Branchproof
|
|
|
106
107
|
rescue ArgumentError => e
|
|
107
108
|
raise ArgumentError, "configuration minimum #{e.message.sub(/\Acoverage minimum for /, "")}"
|
|
108
109
|
end
|
|
110
|
+
|
|
111
|
+
def validate_minimum_changed(value)
|
|
112
|
+
raise ArgumentError, "configuration minimum_changed must be an object" unless value.is_a?(Hash)
|
|
113
|
+
|
|
114
|
+
CoveragePolicy.normalize(value)
|
|
115
|
+
rescue ArgumentError => e
|
|
116
|
+
message = e.message.sub(/\Acoverage minimum for /, "")
|
|
117
|
+
raise ArgumentError, "configuration minimum_changed #{message}"
|
|
118
|
+
end
|
|
109
119
|
end
|
|
110
120
|
end
|
|
111
121
|
end
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
# rubocop:disable Metrics/ClassLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/ParameterLists
|
|
4
4
|
|
|
5
|
+
require_relative "changed_coverage"
|
|
6
|
+
|
|
5
7
|
module Branchproof
|
|
6
8
|
# Evaluates configured coverage thresholds against the aggregate Analyzer
|
|
7
9
|
# coverage counts without relying on rounded percentage fields.
|
|
@@ -164,6 +166,126 @@ module Branchproof
|
|
|
164
166
|
hash.key?(key) || hash.key?(key.to_s) || hash.key?(key.to_sym)
|
|
165
167
|
end
|
|
166
168
|
end
|
|
169
|
+
|
|
170
|
+
# Evaluates gates against the captured changed-scope summary independently
|
|
171
|
+
# from whole-run coverage policy.
|
|
172
|
+
class ChangedCoveragePolicy
|
|
173
|
+
CRITERIA = CoveragePolicy::CRITERIA
|
|
174
|
+
COMPLETENESS_FIELDS = CoveragePolicy::COMPLETENESS_FIELDS
|
|
175
|
+
|
|
176
|
+
def initialize(minimum_changed:)
|
|
177
|
+
raise ArgumentError, "minimum_changed must be a hash" unless minimum_changed.is_a?(Hash)
|
|
178
|
+
|
|
179
|
+
@minimum_changed = CoveragePolicy.normalize(minimum_changed)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def call(document:, scope:)
|
|
183
|
+
return { minimum_changed: @minimum_changed.dup, status: "passed", gates: [] } if @minimum_changed.empty?
|
|
184
|
+
|
|
185
|
+
global_reason = availability_reason(document)
|
|
186
|
+
empty_scope = value(scope, :status) == "empty" && Array(value(scope, :decision_ids)).empty?
|
|
187
|
+
changed_coverage = ChangedCoverage.call(document: document, scope: scope)
|
|
188
|
+
gates = @minimum_changed.map do |criterion, threshold|
|
|
189
|
+
if global_reason
|
|
190
|
+
gate(criterion, threshold, nil, nil, "unavailable", global_reason)
|
|
191
|
+
elsif empty_scope
|
|
192
|
+
gate(criterion, threshold, 0, 0, "not_applicable", "empty_scope")
|
|
193
|
+
else
|
|
194
|
+
evaluate_gate(criterion, threshold, changed_coverage)
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
status = if gates.all? { |item| item[:status] == "not_applicable" }
|
|
198
|
+
"not_applicable"
|
|
199
|
+
elsif gates.any? { |item| item[:status] == "unavailable" }
|
|
200
|
+
"unavailable"
|
|
201
|
+
elsif gates.any? { |item| item[:status] == "failed" }
|
|
202
|
+
"failed"
|
|
203
|
+
else
|
|
204
|
+
"passed"
|
|
205
|
+
end
|
|
206
|
+
{ minimum_changed: @minimum_changed.dup, status: status, gates: gates }
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
private
|
|
210
|
+
|
|
211
|
+
def evaluate_gate(criterion, threshold, summary)
|
|
212
|
+
unless summary.is_a?(Hash) && value(summary, :status) == "available"
|
|
213
|
+
return gate(criterion, threshold, nil, nil, "unavailable",
|
|
214
|
+
value(summary, :reason) || "changed_coverage_unavailable")
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
row = value(value(summary, :coverage), criterion)
|
|
218
|
+
return gate(criterion, threshold, nil, nil, "unavailable", "missing_coverage_count") unless row.is_a?(Hash)
|
|
219
|
+
|
|
220
|
+
numerator = value(row, :numerator)
|
|
221
|
+
denominator = value(row, :denominator)
|
|
222
|
+
reason = value(row, :reason)
|
|
223
|
+
status = value(row, :status).to_s
|
|
224
|
+
unless status == "available"
|
|
225
|
+
return gate(criterion, threshold, numerator, denominator, "unavailable",
|
|
226
|
+
reason || "changed_coverage_unavailable")
|
|
227
|
+
end
|
|
228
|
+
unless valid_count?(numerator) && valid_count?(denominator)
|
|
229
|
+
return gate(criterion, threshold, numerator, denominator, "unavailable", "invalid_coverage_count")
|
|
230
|
+
end
|
|
231
|
+
return gate(criterion, threshold, numerator, denominator, "unavailable", "zero_denominator") if denominator.zero?
|
|
232
|
+
if numerator > denominator
|
|
233
|
+
return gate(criterion, threshold, numerator, denominator, "unavailable", "invalid_coverage_count")
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
result = numerator * 100 >= denominator * Rational(threshold.to_s) ? "passed" : "failed"
|
|
237
|
+
gate(criterion, threshold, numerator, denominator, result, nil)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def availability_reason(document)
|
|
241
|
+
return "document_unavailable" unless document.is_a?(Hash)
|
|
242
|
+
|
|
243
|
+
baseline = value(document, :baseline)
|
|
244
|
+
passed = baseline.is_a?(Hash) && value(baseline, :status).to_s.upcase == "PASSED"
|
|
245
|
+
return "baseline_unavailable" unless passed && value(baseline, :finalized) == true
|
|
246
|
+
|
|
247
|
+
completeness = value(document, :completeness)
|
|
248
|
+
return "incomplete_document" unless complete?(completeness)
|
|
249
|
+
|
|
250
|
+
observations = value(document, :observations) || value(document, :evidence)
|
|
251
|
+
return "incomplete_observations" unless observations.is_a?(Hash) && complete?(value(observations, :completeness))
|
|
252
|
+
|
|
253
|
+
analysis = value(document, :analysis)
|
|
254
|
+
return "incomplete_analysis" unless analysis.is_a?(Hash)
|
|
255
|
+
return "incomplete_analysis" unless complete?(value(analysis, :completeness))
|
|
256
|
+
|
|
257
|
+
nil
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def complete?(completeness)
|
|
261
|
+
completeness.is_a?(Hash) && COMPLETENESS_FIELDS.all? { |field| value(completeness, field) == true }
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def incomplete_section?(section)
|
|
265
|
+
section.is_a?(Hash) && key_present?(section, :completeness) && !complete?(value(section, :completeness))
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def valid_count?(count)
|
|
269
|
+
count.is_a?(Integer) && count >= 0
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def gate(criterion, threshold, numerator, denominator, status, reason)
|
|
273
|
+
{ criterion: criterion, numerator: numerator, denominator: denominator, minimum: threshold,
|
|
274
|
+
status: status, reason: reason }
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def value(hash, key)
|
|
278
|
+
return nil unless hash.respond_to?(:key?)
|
|
279
|
+
return hash[key] if hash.key?(key)
|
|
280
|
+
|
|
281
|
+
alternate = key.is_a?(Symbol) ? key.to_s : key.to_sym
|
|
282
|
+
hash[alternate] if hash.key?(alternate)
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def key_present?(hash, key)
|
|
286
|
+
hash.key?(key) || hash.key?(key.to_s) || hash.key?(key.to_sym)
|
|
287
|
+
end
|
|
288
|
+
end
|
|
167
289
|
end
|
|
168
290
|
|
|
169
291
|
# rubocop:enable Metrics/ClassLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/ParameterLists
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "decision_table"
|
|
4
|
+
|
|
5
|
+
# rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
6
|
+
module Branchproof
|
|
7
|
+
# Totals per-decision analyzer coverage without changing its counting rules.
|
|
8
|
+
module CoverageSummary
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def call(decisions)
|
|
12
|
+
supported = decisions.reject { |decision| value(decision, :unsupported) }
|
|
13
|
+
boolean_supported = supported.reject { |decision| alternative_decision?(decision) }
|
|
14
|
+
decision_covered = boolean_supported.count do |decision|
|
|
15
|
+
value(value(decision, :coverage), :decision)&.then { value(_1, :status) } == "covered"
|
|
16
|
+
end
|
|
17
|
+
condition_decision_covered = boolean_supported.count do |decision|
|
|
18
|
+
value(value(decision, :coverage), :condition_decision)&.then { value(_1, :status) } == "covered"
|
|
19
|
+
end
|
|
20
|
+
condition_count = boolean_supported.sum do |decision|
|
|
21
|
+
value(value(decision, :coverage), :condition)&.then { value(_1, :condition_count) }.to_i
|
|
22
|
+
end
|
|
23
|
+
condition_values = boolean_supported.sum do |decision|
|
|
24
|
+
value(value(decision, :coverage), :condition)&.then { value(_1, :covered_values) }.to_i
|
|
25
|
+
end
|
|
26
|
+
covered_conditions = boolean_supported.sum do |decision|
|
|
27
|
+
value(value(decision, :coverage), :condition)&.then { value(_1, :covered_conditions) }.to_i
|
|
28
|
+
end
|
|
29
|
+
proven = boolean_supported.sum do |decision|
|
|
30
|
+
value(value(decision, :coverage), :mcdc)&.then { value(_1, :proven_conditions) }.to_i
|
|
31
|
+
end
|
|
32
|
+
{
|
|
33
|
+
decision: { covered_decisions: decision_covered, supported_decisions: boolean_supported.length,
|
|
34
|
+
percentage: DecisionTable.percentage(decision_covered, boolean_supported.length) },
|
|
35
|
+
condition: { covered_values: condition_values, required_values: condition_count * 2,
|
|
36
|
+
covered_conditions: covered_conditions, condition_count: condition_count,
|
|
37
|
+
percentage: DecisionTable.percentage(condition_values, condition_count * 2) },
|
|
38
|
+
condition_decision: { covered_decisions: condition_decision_covered,
|
|
39
|
+
supported_decisions: boolean_supported.length,
|
|
40
|
+
percentage: DecisionTable.percentage(condition_decision_covered,
|
|
41
|
+
boolean_supported.length) },
|
|
42
|
+
mcdc: { proven_conditions: proven, supported_conditions: condition_count,
|
|
43
|
+
percentage: DecisionTable.percentage(proven, condition_count) },
|
|
44
|
+
decision_table: decision_table_aggregate(boolean_supported),
|
|
45
|
+
alternative: alternative_aggregate(decisions)
|
|
46
|
+
}
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def decision_table_aggregate(boolean_supported)
|
|
50
|
+
analyzed = boolean_supported.select do |decision|
|
|
51
|
+
value(value(decision, :decision_table), :status) == "calculated"
|
|
52
|
+
end
|
|
53
|
+
not_calculated = boolean_supported.length - analyzed.length
|
|
54
|
+
covered = analyzed.sum { |decision| value(value(decision, :decision_table), :covered_rules).to_i }
|
|
55
|
+
required = analyzed.sum { |decision| value(value(decision, :decision_table), :required_rules).to_i }
|
|
56
|
+
generated = analyzed.sum { |decision| value(value(decision, :decision_table), :generated_rules).to_i }
|
|
57
|
+
impossible = analyzed.sum { |decision| value(value(decision, :decision_table), :impossible_rules).to_i }
|
|
58
|
+
fully_covered = analyzed.count do |decision|
|
|
59
|
+
value(value(decision, :decision_table), :coverage_status) == "covered"
|
|
60
|
+
end
|
|
61
|
+
{ decisions_analyzed: analyzed.length, not_calculated_decisions: not_calculated,
|
|
62
|
+
fully_covered_decisions: fully_covered, covered_rules: covered, required_rules: required,
|
|
63
|
+
generated_rules: generated, impossible_rules: impossible,
|
|
64
|
+
percentage: DecisionTable.percentage(covered, required),
|
|
65
|
+
decision_percentage: DecisionTable.percentage(fully_covered, analyzed.length) }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def alternative_aggregate(decisions)
|
|
69
|
+
flow = decisions.select { |decision| !value(decision, :unsupported) && alternative_decision?(decision) }
|
|
70
|
+
required = flow.sum do |decision|
|
|
71
|
+
value(value(value(decision, :coverage), :alternative), :required_alternatives).to_i
|
|
72
|
+
end
|
|
73
|
+
covered = flow.sum do |decision|
|
|
74
|
+
value(value(value(decision, :coverage), :alternative), :covered_alternatives).to_i
|
|
75
|
+
end
|
|
76
|
+
{ covered_alternatives: covered, required_alternatives: required,
|
|
77
|
+
supported_decisions: flow.length, percentage: DecisionTable.percentage(covered, required) }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def alternative_decision?(decision)
|
|
81
|
+
kind = value(decision, :kind).to_s
|
|
82
|
+
!kind.empty? && kind != "boolean"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def value(hash, key)
|
|
86
|
+
return nil unless hash.respond_to?(:key?)
|
|
87
|
+
return hash[key] if hash.key?(key)
|
|
88
|
+
|
|
89
|
+
alternate = key.is_a?(Symbol) ? key.to_s : key.to_sym
|
|
90
|
+
hash[alternate] if hash.key?(alternate)
|
|
91
|
+
end
|
|
92
|
+
private_class_method :value
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rubygems"
|
|
4
|
+
|
|
5
|
+
module Branchproof
|
|
6
|
+
# Checks static setup facts without loading a project's tests or source files.
|
|
7
|
+
# rubocop:disable-next Metrics/ClassLength -- This service owns one small, cohesive static diagnostic document.
|
|
8
|
+
class Doctor
|
|
9
|
+
LIMITATIONS = [
|
|
10
|
+
"Static checks only: application boot, framework test discovery, runner compatibility, source syntax and " \
|
|
11
|
+
"instrumentation, and coverage are not verified.",
|
|
12
|
+
"A discovered test file may contain no executable tests; runtime loader conflicts and application behavior " \
|
|
13
|
+
"remain unverified."
|
|
14
|
+
].freeze
|
|
15
|
+
FRAMEWORK_GEMS = {
|
|
16
|
+
"minitest" => { name: "minitest", requirement: Gem::Requirement.new(">= 5.25.5", "< 7") },
|
|
17
|
+
"rspec" => { name: "rspec-core", requirement: Gem::Requirement.new("~> 3.13.0") }
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
def initialize(options:, runtime: { engine: RUBY_ENGINE, version: RUBY_VERSION },
|
|
21
|
+
loaded_features: $LOADED_FEATURES,
|
|
22
|
+
gem_sources: { activated: Gem.loaded_specs,
|
|
23
|
+
discoverable: ->(name) { Gem::Specification.find_all_by_name(name) } })
|
|
24
|
+
@options = options
|
|
25
|
+
@runtime_engine = runtime.fetch(:engine).to_s
|
|
26
|
+
@runtime_version = runtime.fetch(:version).to_s
|
|
27
|
+
@loaded_features = loaded_features
|
|
28
|
+
@loaded_specs = gem_sources.fetch(:activated)
|
|
29
|
+
@visible_specs = gem_sources.fetch(:discoverable)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def document(source_files:, test_files:)
|
|
33
|
+
framework = framework_info(@options[:project][:framework])
|
|
34
|
+
checks = build_checks(framework, source_files, test_files)
|
|
35
|
+
result_document(framework: framework, checks: checks, source_files: source_files, test_files: test_files)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.error(message)
|
|
39
|
+
ERROR_DOCUMENT.merge(runtime: { engine: RUBY_ENGINE, version: RUBY_VERSION },
|
|
40
|
+
checks: [{ code: "setup", status: "error", message: message.to_s }])
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.terminal(document) = TerminalRenderer.new(document).render
|
|
44
|
+
|
|
45
|
+
ERROR_DOCUMENT = {
|
|
46
|
+
schema_version: 1, command: "doctor", status: "blocked", project: nil, configuration: nil,
|
|
47
|
+
selection: nil, framework: nil, limitations: LIMITATIONS
|
|
48
|
+
}.freeze
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def build_checks(framework, source_files, test_files)
|
|
53
|
+
checks = [runtime_check, framework_check(framework), selection_check("sources", source_files),
|
|
54
|
+
selection_check("tests", test_files)]
|
|
55
|
+
loaded_runtime_warning(checks)
|
|
56
|
+
checks
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def result_document(framework:, checks:, source_files:, test_files:)
|
|
60
|
+
status = checks.any? { |check| check[:status] == "error" } ? "blocked" : "ready"
|
|
61
|
+
{
|
|
62
|
+
schema_version: 1, command: "doctor", status: status,
|
|
63
|
+
runtime: { engine: @runtime_engine, version: @runtime_version }, project: @options[:project],
|
|
64
|
+
configuration: configuration_info(@options[:configuration], @options),
|
|
65
|
+
selection: selection_info(source_files, test_files), framework: framework,
|
|
66
|
+
checks: checks, limitations: LIMITATIONS
|
|
67
|
+
}
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def runtime_check
|
|
71
|
+
supported = @runtime_engine == "ruby" && Gem::Version.new(@runtime_version) >= Gem::Version.new("4.0")
|
|
72
|
+
message = "CRuby >= 4.0 required; found #{@runtime_engine} #{@runtime_version}"
|
|
73
|
+
check("runtime", supported ? "pass" : "error", message)
|
|
74
|
+
rescue ArgumentError
|
|
75
|
+
check("runtime", "error", "could not parse Ruby version #{@runtime_version.inspect}")
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def framework_check(framework)
|
|
79
|
+
requirement = FRAMEWORK_GEMS.fetch(framework[:name])[:requirement]
|
|
80
|
+
version = framework[:version] && Gem::Version.new(framework[:version])
|
|
81
|
+
supported = version && requirement.satisfied_by?(version)
|
|
82
|
+
state = supported ? "pass" : "error"
|
|
83
|
+
check("framework", state, framework_message(framework, requirement, version, supported))
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def framework_message(framework, requirement, version, supported)
|
|
87
|
+
if supported
|
|
88
|
+
"#{framework[:gem]} #{version} is #{framework[:availability]}; version metadata is supported"
|
|
89
|
+
elsif version
|
|
90
|
+
"#{framework[:gem]} #{version} is #{framework[:availability]}, outside supported range #{requirement}; " \
|
|
91
|
+
"add #{framework[:gem]} #{requirement} to the current test bundle"
|
|
92
|
+
else
|
|
93
|
+
"#{framework[:gem]} is not available; add #{framework[:gem]} #{requirement} to the current test bundle"
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def selection_check(kind, files)
|
|
98
|
+
count = files.length
|
|
99
|
+
if count.positive?
|
|
100
|
+
check("#{kind}_selected", "pass", "#{count} #{kind.sub(/s\z/, "")} file(s) selected")
|
|
101
|
+
else
|
|
102
|
+
advice = selection_advice(kind)
|
|
103
|
+
check("#{kind}_selected", "error", "no #{kind.sub(/s\z/, "")} files selected; #{advice}")
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def selection_advice(kind)
|
|
108
|
+
return "check source globs and excludes" if kind == "sources"
|
|
109
|
+
|
|
110
|
+
"check --test, configured tests, or project test patterns"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def loaded_runtime_warning(checks)
|
|
114
|
+
conflicts = loaded_runtime_conflicts
|
|
115
|
+
return if conflicts.empty?
|
|
116
|
+
|
|
117
|
+
checks << check("loaded_runtime", "warning",
|
|
118
|
+
loaded_runtime_message(conflicts))
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def loaded_runtime_message(conflicts)
|
|
122
|
+
"#{conflicts.join(" and ")} already loaded; loader conflicts are not exercised by this static check"
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def loaded_runtime_conflicts
|
|
126
|
+
@loaded_features.filter_map do |feature|
|
|
127
|
+
path = feature.to_s
|
|
128
|
+
if path.match?(%r{(?:\A|/)spring(?:/|\.|\z)}i)
|
|
129
|
+
"Spring"
|
|
130
|
+
elsif path.match?(%r{(?:\A|/)bootsnap(?:/|\.|\z)}i)
|
|
131
|
+
"Bootsnap"
|
|
132
|
+
end
|
|
133
|
+
end.uniq
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def framework_info(framework)
|
|
137
|
+
requirement = FRAMEWORK_GEMS.fetch(framework)
|
|
138
|
+
spec = @loaded_specs[requirement[:name]]
|
|
139
|
+
availability = "activated"
|
|
140
|
+
unless spec
|
|
141
|
+
spec = Array(@visible_specs.call(requirement[:name])).max_by(&:version)
|
|
142
|
+
availability = "discoverable" if spec
|
|
143
|
+
end
|
|
144
|
+
{ name: framework, gem: requirement[:name], version: spec&.version&.to_s,
|
|
145
|
+
availability: spec ? availability : "missing", requirement: requirement[:requirement].to_s }
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def configuration_info(config, options)
|
|
149
|
+
{ state: configuration_state(config, options), path: configuration_path(config, options),
|
|
150
|
+
minimum: config ? config.fetch(:minimum, {}) : {},
|
|
151
|
+
minimum_changed: config ? config.fetch(:minimum_changed, {}) : {} }
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def configuration_state(config, options)
|
|
155
|
+
return "disabled" if options[:config_disabled]
|
|
156
|
+
return "loaded" if config
|
|
157
|
+
|
|
158
|
+
"default absent"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def configuration_path(config, options)
|
|
162
|
+
return nil if options[:config_disabled]
|
|
163
|
+
|
|
164
|
+
config ? config[:path] : File.expand_path(".branchproof.json", options[:project][:root])
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def selection_info(source_files, test_files)
|
|
168
|
+
{
|
|
169
|
+
source_patterns: @options[:source_patterns],
|
|
170
|
+
test_patterns: @options[:test_patterns],
|
|
171
|
+
excludes: @options[:exclude],
|
|
172
|
+
source_count: source_files.length,
|
|
173
|
+
test_count: test_files.length
|
|
174
|
+
}
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def check(code, status, message)
|
|
178
|
+
{ code: code, status: status, message: message }
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Checks static setup facts and renders diagnostics without executing project code.
|
|
183
|
+
class Doctor
|
|
184
|
+
# Formats the structured document as concise terminal output.
|
|
185
|
+
class TerminalRenderer
|
|
186
|
+
def initialize(document)
|
|
187
|
+
@document = document
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def render
|
|
191
|
+
lines = header_lines
|
|
192
|
+
lines.concat(project_lines, configuration_lines, selection_lines, check_lines, limitation_lines)
|
|
193
|
+
"#{lines.join("\n")}\n"
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
private
|
|
197
|
+
|
|
198
|
+
def header_lines
|
|
199
|
+
["Branchproof doctor: #{@document[:status]}",
|
|
200
|
+
"Ruby: #{@document.dig(:runtime, :engine)} #{@document.dig(:runtime, :version)}"]
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def project_lines
|
|
204
|
+
project = @document[:project]
|
|
205
|
+
return [] unless project
|
|
206
|
+
|
|
207
|
+
framework = @document[:framework]
|
|
208
|
+
["Project: #{project[:kind]} (#{project[:root]})",
|
|
209
|
+
"Framework: #{framework[:name]} #{framework[:version] || "unavailable"} (#{framework[:availability]})"]
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def configuration_lines
|
|
213
|
+
configuration = @document[:configuration]
|
|
214
|
+
return [] unless configuration
|
|
215
|
+
|
|
216
|
+
path = configuration[:path] ? " (#{configuration[:path]})" : nil
|
|
217
|
+
minimum = configuration[:minimum].empty? ? "none" : configuration[:minimum].inspect
|
|
218
|
+
changed = configuration[:minimum_changed].empty? ? "none" : configuration[:minimum_changed].inspect
|
|
219
|
+
["Config: #{configuration[:state]}#{path}", "Configured minima: #{minimum} (not evaluated)",
|
|
220
|
+
"Configured changed minima: #{changed} (not evaluated)"]
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def selection_lines
|
|
224
|
+
selection = @document[:selection]
|
|
225
|
+
return [] unless selection
|
|
226
|
+
|
|
227
|
+
["Sources: #{selection[:source_count]} files from #{selection[:source_patterns].join(", ")}",
|
|
228
|
+
"Tests: #{selection[:test_count]} files from #{selection[:test_patterns].join(", ")}",
|
|
229
|
+
"Excludes: #{selection[:excludes].empty? ? "none" : selection[:excludes].join(", ")}"]
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def check_lines
|
|
233
|
+
@document[:checks].map { |check| "#{check[:status].upcase}: #{check[:message]}" }
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def limitation_lines
|
|
237
|
+
@document[:limitations].map { |limitation| "Static-only: #{limitation}" }
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
data/lib/branchproof/evidence.rb
CHANGED
|
@@ -37,6 +37,7 @@ module Branchproof
|
|
|
37
37
|
@repetition_cache = {}
|
|
38
38
|
@limited = false
|
|
39
39
|
@attribution_complete = true
|
|
40
|
+
@analysis_complete = true
|
|
40
41
|
end
|
|
41
42
|
|
|
42
43
|
def register_test(test:)
|
|
@@ -134,7 +135,7 @@ module Branchproof
|
|
|
134
135
|
tests: @tests.values, vectors: @vectors.values,
|
|
135
136
|
abort_counts: @abort_counts.dup, diagnostics: @diagnostics.map(&:dup),
|
|
136
137
|
completeness: { observation: !@limited, attribution: @attribution_complete,
|
|
137
|
-
analysis:
|
|
138
|
+
analysis: @analysis_complete } }))
|
|
138
139
|
end
|
|
139
140
|
|
|
140
141
|
def merge(snapshot:)
|
|
@@ -180,6 +181,7 @@ module Branchproof
|
|
|
180
181
|
@abort_counts.merge!(incoming.fetch(:abort_counts, {})) { |_k, a, b| a.to_i + b.to_i }
|
|
181
182
|
@limited ||= !incoming.dig(:completeness, :observation)
|
|
182
183
|
@attribution_complete &&= incoming.dig(:completeness, :attribution) ? true : false
|
|
184
|
+
@analysis_complete &&= incoming.dig(:completeness, :analysis) ? true : false
|
|
183
185
|
@repetition_cache.clear
|
|
184
186
|
status("merged", nil)
|
|
185
187
|
rescue StandardError => e
|
|
@@ -565,6 +567,7 @@ module Branchproof
|
|
|
565
567
|
{ vectors: deep_dup(@vectors), tests: deep_dup(@tests), run_ids: @run_ids.dup,
|
|
566
568
|
run_payloads: @run_payloads.dup, diagnostics: deep_dup(@diagnostics),
|
|
567
569
|
abort_counts: @abort_counts.dup, limited: @limited, attribution_complete: @attribution_complete,
|
|
570
|
+
analysis_complete: @analysis_complete,
|
|
568
571
|
vector_counts_by_decision: @vector_counts_by_decision.dup, owner_associations_count: @owner_associations_count,
|
|
569
572
|
repetition_cache: deep_dup(@repetition_cache) }
|
|
570
573
|
end
|
|
@@ -581,6 +584,7 @@ module Branchproof
|
|
|
581
584
|
@repetition_cache = state[:repetition_cache]
|
|
582
585
|
@limited = state[:limited]
|
|
583
586
|
@attribution_complete = state[:attribution_complete]
|
|
587
|
+
@analysis_complete = state[:analysis_complete]
|
|
584
588
|
end
|
|
585
589
|
|
|
586
590
|
def cached_execution(execution)
|
|
@@ -41,9 +41,10 @@ module Branchproof
|
|
|
41
41
|
replacements << { start: transfer.fetch(:insert_at), length: 0,
|
|
42
42
|
text: "#{runtime}.exception_path(#{identifier}, 0); " }
|
|
43
43
|
end
|
|
44
|
-
elsif metadata[:
|
|
45
|
-
|
|
46
|
-
|
|
44
|
+
elsif metadata[:normal_value] && metadata[:normal_value][:byte_length].positive?
|
|
45
|
+
local = metadata.fetch(:normal_value_local)
|
|
46
|
+
replacements << flow_replacement(bytes, metadata.fetch(:normal_value), nested, encloses) do |value|
|
|
47
|
+
"begin; #{local} = (#{value}); #{runtime}.exception_value(#{identifier}, #{local}, 0); end"
|
|
47
48
|
end
|
|
48
49
|
end
|
|
49
50
|
metadata.fetch(:clauses).each do |clause|
|
|
@@ -53,7 +53,8 @@ module Branchproof
|
|
|
53
53
|
type: "exception",
|
|
54
54
|
range: byte_range(node.location),
|
|
55
55
|
normal_insert_at: exception_normal_insert_at(node),
|
|
56
|
-
|
|
56
|
+
normal_value: exception_normal_value(node.statements),
|
|
57
|
+
normal_value_local: exception_normal_value_local(node, bytes),
|
|
57
58
|
normal_transfer: exception_transfer_metadata(node.statements),
|
|
58
59
|
implicit: node.begin_keyword_loc.nil?,
|
|
59
60
|
normal_empty: node.begin_keyword_loc.nil? && exception_body_empty?(node),
|
|
@@ -137,6 +138,19 @@ module Branchproof
|
|
|
137
138
|
[Prism::ReturnNode, Prism::BreakNode, Prism::NextNode, Prism::RedoNode].any? { |klass| node.is_a?(klass) }
|
|
138
139
|
end
|
|
139
140
|
|
|
141
|
+
def exception_normal_value(statements)
|
|
142
|
+
value = Array(statements&.body).last
|
|
143
|
+
return unless value
|
|
144
|
+
|
|
145
|
+
byte_range(value.location)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def exception_normal_value_local(node, bytes)
|
|
149
|
+
name = "__branchproof_exception_value_#{node.location.start_offset}"
|
|
150
|
+
name += "_" while bytes.include?(name)
|
|
151
|
+
name
|
|
152
|
+
end
|
|
153
|
+
|
|
140
154
|
def exception_body_empty?(node)
|
|
141
155
|
node.statements.nil? || statements_empty?(node.statements)
|
|
142
156
|
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Branchproof
|
|
4
|
+
# Wraps each fallback operand so the runtime sees the operand's own value.
|
|
5
|
+
# `||` still short-circuits, so later operands are never evaluated early.
|
|
6
|
+
module FallbackInstrumentation
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def render_flow(bytes, decision, nested, encloses)
|
|
10
|
+
metadata = decision.fetch(:instrumentation)
|
|
11
|
+
return super unless metadata[:type] == "fallback"
|
|
12
|
+
|
|
13
|
+
identifier = decision[:id].inspect
|
|
14
|
+
implicit = decision[:kind] == "implicit"
|
|
15
|
+
replacements = metadata.fetch(:operands).each_with_index.map do |operand, index|
|
|
16
|
+
flow_replacement(bytes, operand, nested, encloses) do |expression|
|
|
17
|
+
"#{self.class::RUNTIME}.fallback_operand(#{identifier}, #{index}, #{implicit}, (#{expression}))"
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
# No default_path: fallback_chain? guarantees the last operand is a truthy
|
|
21
|
+
# literal, so the frame always finishes through fallback_operand above.
|
|
22
|
+
flow_frame(decision[:id], flow_fragments(bytes, decision, nested, replacements, encloses))
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|