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
|
@@ -6,21 +6,27 @@ module Branchproof
|
|
|
6
6
|
# Validates and applies terminal-only report display filters.
|
|
7
7
|
# rubocop:disable Metrics/ClassLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
8
8
|
class ReportSelection
|
|
9
|
-
attr_reader :focus, :top
|
|
9
|
+
attr_reader :focus, :top, :decision_ids
|
|
10
10
|
|
|
11
|
-
def initialize(focus: nil, top: nil)
|
|
11
|
+
def initialize(focus: nil, top: nil, decision_ids: nil)
|
|
12
12
|
@focus = normalize_focus(focus)
|
|
13
13
|
@top = normalize_top(top)
|
|
14
|
+
@decision_ids = decision_ids.nil? ? nil : Array(decision_ids).map(&:to_s).uniq.freeze
|
|
15
|
+
@decision_id_set = @decision_ids&.to_h { |id| [id, true] }
|
|
14
16
|
end
|
|
15
17
|
|
|
16
18
|
def active?
|
|
17
|
-
!@focus.nil? || !@top.nil?
|
|
19
|
+
!@focus.nil? || !@top.nil? || !@decision_ids.nil?
|
|
18
20
|
end
|
|
19
21
|
|
|
20
22
|
def focus_active?
|
|
21
23
|
!@focus.nil?
|
|
22
24
|
end
|
|
23
25
|
|
|
26
|
+
def decision_filter_active?
|
|
27
|
+
focus_active? || !@decision_ids.nil?
|
|
28
|
+
end
|
|
29
|
+
|
|
24
30
|
def focus_label
|
|
25
31
|
return unless @focus
|
|
26
32
|
|
|
@@ -37,11 +43,26 @@ module Branchproof
|
|
|
37
43
|
end
|
|
38
44
|
end
|
|
39
45
|
|
|
46
|
+
def selected_decision_ids(document)
|
|
47
|
+
ids = decision_ids
|
|
48
|
+
focus_ids = matching_decision_ids(document) if focus_active?
|
|
49
|
+
return focus_ids unless ids
|
|
50
|
+
return ids unless focus_active?
|
|
51
|
+
|
|
52
|
+
focus_set = focus_ids.to_h { |id| [id, true] }
|
|
53
|
+
ids.select { |id| focus_set.key?(id) }
|
|
54
|
+
end
|
|
55
|
+
|
|
40
56
|
def filter_decisions(decisions, inventory: {})
|
|
41
|
-
return decisions unless
|
|
57
|
+
return decisions unless decision_filter_active?
|
|
42
58
|
|
|
43
59
|
sources = source_map(inventory)
|
|
44
|
-
decisions.select
|
|
60
|
+
decisions.select do |decision|
|
|
61
|
+
id = fetch(decision, :id).to_s
|
|
62
|
+
in_scope = !@decision_id_set || @decision_id_set.key?(id)
|
|
63
|
+
in_focus = !focus_active? || matches_decision?(decision, sources)
|
|
64
|
+
in_scope && in_focus
|
|
65
|
+
end
|
|
45
66
|
end
|
|
46
67
|
|
|
47
68
|
def limit(items)
|
data/lib/branchproof/runtime.rb
CHANGED
|
@@ -7,6 +7,7 @@ require_relative "exception_runtime"
|
|
|
7
7
|
require_relative "default_runtime"
|
|
8
8
|
require_relative "value_runtime"
|
|
9
9
|
require_relative "iteration_runtime"
|
|
10
|
+
require_relative "fallback_runtime"
|
|
10
11
|
|
|
11
12
|
module Branchproof
|
|
12
13
|
# Process-local execution recorder. It deliberately never coerces or stores
|
|
@@ -19,6 +20,7 @@ module Branchproof
|
|
|
19
20
|
extend DefaultRuntime
|
|
20
21
|
extend ValueRuntime
|
|
21
22
|
extend IterationRuntime
|
|
23
|
+
extend FallbackRuntime
|
|
22
24
|
|
|
23
25
|
FRAME_STATE_KEY = :branchproof_runtime_frame_state
|
|
24
26
|
|
|
@@ -8,13 +8,14 @@ require "json"
|
|
|
8
8
|
# rubocop:disable-next Lint/RedundantRequireStatement -- supports standalone core entry
|
|
9
9
|
require "set"
|
|
10
10
|
require_relative "coverage_policy"
|
|
11
|
+
require_relative "changed_coverage"
|
|
11
12
|
require_relative "decision_table"
|
|
12
13
|
|
|
13
14
|
module Branchproof
|
|
14
15
|
# Reads and validates a persisted JSON report without loading the project.
|
|
15
16
|
class SavedReport
|
|
16
|
-
SUPPORTED_SCHEMAS = %w[1.0 1.1 1.2 1.3 1.4].freeze
|
|
17
|
-
STRICT_FLOW_SCHEMAS = %w[1.2 1.3 1.4].freeze
|
|
17
|
+
SUPPORTED_SCHEMAS = %w[1.0 1.1 1.2 1.3 1.4 1.5 1.6 1.7].freeze
|
|
18
|
+
STRICT_FLOW_SCHEMAS = %w[1.2 1.3 1.4 1.5 1.6 1.7].freeze
|
|
18
19
|
DECISION_TABLE_STATUSES = %w[calculated not_calculated].freeze
|
|
19
20
|
RULE_CONDITION_VALUES = DecisionTable::CONDITION_VALUES
|
|
20
21
|
RULE_COVERAGE_STATUSES = DecisionTable::COVERAGE_STATUSES
|
|
@@ -49,7 +50,8 @@ module Branchproof
|
|
|
49
50
|
@schema_version = @document["schema_version"]
|
|
50
51
|
schema = @schema_version
|
|
51
52
|
fail_with("unsupported schema version") unless SUPPORTED_SCHEMAS.include?(schema)
|
|
52
|
-
|
|
53
|
+
required_fields = REQUIRED_FIELDS + (@schema_version == "1.7" ? ["collation"] : [])
|
|
54
|
+
missing = required_fields.reject { |field| @document.key?(field) }
|
|
53
55
|
fail_with("missing field: #{missing.first}") unless missing.empty?
|
|
54
56
|
fail_with("criterion version mismatch") unless @document["criterion_version"] == CRITERION_VERSION
|
|
55
57
|
fail_with("run_ids must be an array of strings") unless strings?(@document["run_ids"])
|
|
@@ -62,7 +64,14 @@ module Branchproof
|
|
|
62
64
|
validate_completeness(@document.dig("observations", "completeness"))
|
|
63
65
|
validate_completeness(@document.dig("analysis", "completeness")) if @document["analysis"]
|
|
64
66
|
validate_coverage_policy
|
|
67
|
+
validate_changed_scope
|
|
68
|
+
validate_changed_coverage_policy
|
|
65
69
|
validate_optional_sections
|
|
70
|
+
if @schema_version == "1.7"
|
|
71
|
+
validate_collation
|
|
72
|
+
elsif @document.key?("collation")
|
|
73
|
+
fail_with("collation is unsupported by this report schema")
|
|
74
|
+
end
|
|
66
75
|
validate_analysis_coverage
|
|
67
76
|
@document
|
|
68
77
|
end
|
|
@@ -220,7 +229,7 @@ module Branchproof
|
|
|
220
229
|
fail_with("nonboolean condition results must be empty") unless results.empty?
|
|
221
230
|
validate_nonboolean_analysis(decision, inventory_decision)
|
|
222
231
|
elsif decision.key?("decision_table")
|
|
223
|
-
unless %w[1.3 1.4].include?(@schema_version)
|
|
232
|
+
unless %w[1.3 1.4 1.5 1.6 1.7].include?(@schema_version)
|
|
224
233
|
fail_with("decision tables are unsupported in legacy report schemas")
|
|
225
234
|
end
|
|
226
235
|
validate_decision_table(decision["decision_table"], inventory_decision)
|
|
@@ -632,7 +641,7 @@ module Branchproof
|
|
|
632
641
|
|
|
633
642
|
def validate_coverage_policy
|
|
634
643
|
policy = @document["coverage_policy"]
|
|
635
|
-
fail_with("missing field: coverage_policy") if
|
|
644
|
+
fail_with("missing field: coverage_policy") if %w[1.4 1.5 1.6 1.7].include?(@schema_version) && !policy
|
|
636
645
|
return if policy.nil?
|
|
637
646
|
|
|
638
647
|
fail_with("coverage_policy must be an object") unless hash_with_string_keys?(policy)
|
|
@@ -652,6 +661,315 @@ module Branchproof
|
|
|
652
661
|
fail_with(e.message.sub(/\Ainvalid saved report: /, ""))
|
|
653
662
|
end
|
|
654
663
|
|
|
664
|
+
def validate_changed_scope
|
|
665
|
+
scope_present = @document.key?("changed_scope")
|
|
666
|
+
summary_present = @document.key?("changed_coverage")
|
|
667
|
+
return if @schema_version == "1.7" && !scope_present && !summary_present
|
|
668
|
+
|
|
669
|
+
unless %w[1.5 1.6 1.7].include?(@schema_version)
|
|
670
|
+
fail_with("changed scope is unsupported by this report schema") if scope_present || summary_present
|
|
671
|
+
return
|
|
672
|
+
end
|
|
673
|
+
fail_with("missing field: changed_scope") unless scope_present
|
|
674
|
+
fail_with("missing field: changed_coverage") unless summary_present
|
|
675
|
+
|
|
676
|
+
scope = @document["changed_scope"]
|
|
677
|
+
fail_with("changed_scope must be an object") unless hash_with_string_keys?(scope)
|
|
678
|
+
fail_with("changed_scope version is unsupported") unless scope["version"] == "1.0"
|
|
679
|
+
unless nonempty_string?(scope["requested_ref"]) && !scope["requested_ref"].include?("\0")
|
|
680
|
+
fail_with("changed_scope requested_ref must be a usable Git ref string")
|
|
681
|
+
end
|
|
682
|
+
unless scope["base_commit"].is_a?(String) && scope["base_commit"].match?(/\A(?:[0-9a-f]{40}|[0-9a-f]{64})\z/i)
|
|
683
|
+
fail_with("changed_scope base_commit must be a full commit id")
|
|
684
|
+
end
|
|
685
|
+
fail_with("changed_scope comparison is invalid") unless scope["comparison"] == "tracked_worktree"
|
|
686
|
+
fail_with("changed_scope untracked policy is invalid") unless scope["untracked"] == "excluded"
|
|
687
|
+
fail_with("changed_scope status is invalid") unless %w[complete empty].include?(scope["status"])
|
|
688
|
+
files = scope["files"]
|
|
689
|
+
fail_with("changed_scope files must be an array") unless files.is_a?(Array)
|
|
690
|
+
ids = scope["decision_ids"]
|
|
691
|
+
fail_with("changed_scope decision_ids must be an array of strings") unless strings?(ids)
|
|
692
|
+
fail_with("duplicate changed decision id") unless ids.uniq.length == ids.length
|
|
693
|
+
fail_with("changed_scope status does not match selected decisions") if (scope["status"] == "empty") != ids.empty?
|
|
694
|
+
|
|
695
|
+
changed_paths = validate_changed_files(files)
|
|
696
|
+
inventory_order = @decision_ids
|
|
697
|
+
fail_with("unknown changed decision id") unless (ids - inventory_order).empty?
|
|
698
|
+
fail_with("changed decision ids are out of inventory order") unless ids == inventory_order.select do |id|
|
|
699
|
+
ids.include?(id)
|
|
700
|
+
end
|
|
701
|
+
current_paths = @document.dig("source_inventory", "source_units").to_h do |source|
|
|
702
|
+
[source["source_id"], source["relative_path"]]
|
|
703
|
+
end
|
|
704
|
+
selected_on_changed_path = ids.all? do |id|
|
|
705
|
+
decision = @decisions_by_id.fetch(id)
|
|
706
|
+
path = current_paths[decision["source_id"]]
|
|
707
|
+
path && changed_paths.include?(path)
|
|
708
|
+
end
|
|
709
|
+
fail_with("changed decision does not belong to a current changed file") unless selected_on_changed_path
|
|
710
|
+
|
|
711
|
+
expected = JSON.parse(JSON.generate(ChangedCoverage.call(document: @document, scope: scope)))
|
|
712
|
+
actual = JSON.parse(JSON.generate(@document["changed_coverage"]))
|
|
713
|
+
fail_with("changed_coverage does not match captured analysis") unless actual == expected
|
|
714
|
+
end
|
|
715
|
+
|
|
716
|
+
def validate_changed_coverage_policy
|
|
717
|
+
policy_present = @document.key?("changed_coverage_policy")
|
|
718
|
+
if @schema_version == "1.6"
|
|
719
|
+
fail_with("missing field: changed_coverage_policy") unless policy_present
|
|
720
|
+
elsif @schema_version == "1.7"
|
|
721
|
+
return unless policy_present
|
|
722
|
+
|
|
723
|
+
fail_with("changed policy requires captured scope") unless @document["changed_scope"]
|
|
724
|
+
elsif policy_present
|
|
725
|
+
fail_with("changed_coverage_policy is unsupported by this report schema")
|
|
726
|
+
else
|
|
727
|
+
return
|
|
728
|
+
end
|
|
729
|
+
|
|
730
|
+
policy = @document["changed_coverage_policy"]
|
|
731
|
+
fail_with("changed_coverage_policy must be an object") unless hash_with_string_keys?(policy)
|
|
732
|
+
minimum = policy["minimum_changed"]
|
|
733
|
+
fail_with("changed_coverage_policy minimum_changed must be an object") unless hash_with_string_keys?(minimum)
|
|
734
|
+
normalized = CoveragePolicy.normalize(minimum)
|
|
735
|
+
fail_with("changed_coverage_policy minimum_changed must not be empty") if normalized.empty?
|
|
736
|
+
fail_with("changed_coverage_policy status must be a string") unless policy["status"].is_a?(String)
|
|
737
|
+
fail_with("changed_coverage_policy gates must be an array") unless policy["gates"].is_a?(Array)
|
|
738
|
+
expected = ChangedCoveragePolicy.new(minimum_changed: normalized).call(
|
|
739
|
+
document: @document, scope: @document["changed_scope"]
|
|
740
|
+
)
|
|
741
|
+
actual = JSON.parse(JSON.generate(policy))
|
|
742
|
+
expected = JSON.parse(JSON.generate(expected))
|
|
743
|
+
fail_with("changed_coverage_policy does not match captured analysis") unless actual == expected
|
|
744
|
+
rescue ArgumentError => e
|
|
745
|
+
fail_with(e.message.sub(/\Ainvalid saved report: /, ""))
|
|
746
|
+
end
|
|
747
|
+
|
|
748
|
+
def validate_collation
|
|
749
|
+
data = @document["collation"]
|
|
750
|
+
fail_with("collation must be an object") unless hash_with_string_keys?(data)
|
|
751
|
+
fail_with("collation version is unsupported") unless data["version"] == "1.0"
|
|
752
|
+
statuses = %w[complete incomplete unknown]
|
|
753
|
+
status = data["collection_status"]
|
|
754
|
+
fail_with("collation collection_status is invalid") unless statuses.include?(status)
|
|
755
|
+
expected = data["expected_shards"]
|
|
756
|
+
if !expected.nil? && (!strings?(expected) || expected.empty? || expected.any? { |id| !nonempty_string?(id) } ||
|
|
757
|
+
expected.uniq.length != expected.length)
|
|
758
|
+
fail_with("collation expected_shards must be null or unique nonempty IDs")
|
|
759
|
+
end
|
|
760
|
+
missing = data["missing_shards"]
|
|
761
|
+
unless strings?(missing) && missing.uniq.length == missing.length
|
|
762
|
+
fail_with("collation missing_shards must be unique IDs")
|
|
763
|
+
end
|
|
764
|
+
shards = data["shards"]
|
|
765
|
+
fail_with("collation shards must be a nonempty array") unless shards.is_a?(Array) && !shards.empty?
|
|
766
|
+
ids = []
|
|
767
|
+
digests = []
|
|
768
|
+
run_ids = []
|
|
769
|
+
shard_baselines = []
|
|
770
|
+
shards.each do |shard|
|
|
771
|
+
fail_with("invalid collation shard") unless hash_with_string_keys?(shard)
|
|
772
|
+
id = shard["id"]
|
|
773
|
+
fail_with("collation shard ID must be a nonempty string") unless nonempty_string?(id)
|
|
774
|
+
ids << id
|
|
775
|
+
paths = shard["paths"]
|
|
776
|
+
valid_paths = strings?(paths) && !paths.empty? && paths.all? { |path| nonempty_string?(path) }
|
|
777
|
+
fail_with("collation shard paths must be nonempty strings") unless valid_paths
|
|
778
|
+
digest = shard["report_digest"]
|
|
779
|
+
unless digest.is_a?(String) && digest.match?(/\A[0-9a-f]{64}\z/)
|
|
780
|
+
fail_with("collation report digest must be SHA-256")
|
|
781
|
+
end
|
|
782
|
+
digests << digest
|
|
783
|
+
shard_runs = shard["run_ids"]
|
|
784
|
+
valid_run_ids = strings?(shard_runs) && !shard_runs.empty? &&
|
|
785
|
+
shard_runs.all? { |value| nonempty_string?(value) } &&
|
|
786
|
+
shard_runs.uniq.length == shard_runs.length
|
|
787
|
+
fail_with("collation shard run_ids must be nonempty unique IDs") unless valid_run_ids
|
|
788
|
+
run_ids.concat(shard_runs)
|
|
789
|
+
baseline = shard["baseline"]
|
|
790
|
+
fail_with("invalid collation shard baseline") unless hash_with_string_keys?(baseline)
|
|
791
|
+
fail_with("collation shard baseline status is invalid") unless %w[PASSED FAILED ERROR
|
|
792
|
+
INCOMPLETE].include?(baseline["status"])
|
|
793
|
+
%w[executed_tests failed_tests skipped_tests].each do |field|
|
|
794
|
+
unless baseline[field].is_a?(Integer) && baseline[field] >= 0
|
|
795
|
+
fail_with("collation shard baseline #{field} must be nonnegative")
|
|
796
|
+
end
|
|
797
|
+
end
|
|
798
|
+
fail_with("collation shard baseline finalized must be boolean") unless [true,
|
|
799
|
+
false].include?(baseline["finalized"])
|
|
800
|
+
fail_with("collation shard baseline status must be a string") unless baseline["status"].is_a?(String)
|
|
801
|
+
fail_with("collation shard failed and skipped counts exceed executed tests") if
|
|
802
|
+
baseline["failed_tests"] + baseline["skipped_tests"] > baseline["executed_tests"]
|
|
803
|
+
fail_with("PASSED collation shard cannot contain failures") if baseline["status"] == "PASSED" &&
|
|
804
|
+
baseline["failed_tests"].positive?
|
|
805
|
+
fail_with("zero-test collation shard cannot pass") if baseline["status"] == "PASSED" &&
|
|
806
|
+
baseline["executed_tests"].zero?
|
|
807
|
+
completeness = shard["completeness"]
|
|
808
|
+
validate_completeness(completeness)
|
|
809
|
+
fail_with("collation shard run_metadata must be an object") unless hash_with_string_keys?(shard["run_metadata"])
|
|
810
|
+
shard_baselines << baseline
|
|
811
|
+
end
|
|
812
|
+
fail_with("duplicate collation shard ID") unless ids.uniq.length == ids.length
|
|
813
|
+
fail_with("duplicate collation report digest") unless digests.uniq.length == digests.length
|
|
814
|
+
fail_with("overlapping collation run IDs") unless run_ids.uniq.length == run_ids.length
|
|
815
|
+
fail_with("collation run IDs do not match report run_ids") unless @document["run_ids"].sort == run_ids.sort
|
|
816
|
+
observations = @document["observations"]
|
|
817
|
+
unless observations["run_ids"] == @document["run_ids"]
|
|
818
|
+
fail_with("collation observation run_ids do not match report run_ids")
|
|
819
|
+
end
|
|
820
|
+
validate_collated_test_statuses(observations)
|
|
821
|
+
expected_missing = expected ? expected - ids : []
|
|
822
|
+
fail_with("collation contains undeclared shard IDs") if expected && (ids - expected).any?
|
|
823
|
+
fail_with("collation missing_shards do not match expected IDs") unless missing == expected_missing
|
|
824
|
+
expected_status = if expected.nil?
|
|
825
|
+
"unknown"
|
|
826
|
+
elsif expected_missing.empty?
|
|
827
|
+
"complete"
|
|
828
|
+
else
|
|
829
|
+
"incomplete"
|
|
830
|
+
end
|
|
831
|
+
fail_with("collation collection status does not match shard IDs") unless status == expected_status
|
|
832
|
+
counts = %w[executed_tests failed_tests skipped_tests].to_h do |field|
|
|
833
|
+
[field, shard_baselines.sum { |baseline| baseline.fetch(field) }]
|
|
834
|
+
end
|
|
835
|
+
counts.each do |field, value|
|
|
836
|
+
next if @document.dig("baseline", field) == value
|
|
837
|
+
|
|
838
|
+
fail_with("collation aggregate #{field} does not match provenance")
|
|
839
|
+
end
|
|
840
|
+
forced_incomplete = status != "complete" || shard_baselines.any? do |baseline|
|
|
841
|
+
baseline["status"] == "INCOMPLETE" || baseline["finalized"] != true
|
|
842
|
+
end || shards.any? { |shard| shard.fetch("completeness").values.any? { |complete| complete != true } }
|
|
843
|
+
forced_error = shard_baselines.any? { |baseline| baseline["status"] == "ERROR" }
|
|
844
|
+
forced_failed = shard_baselines.any? do |baseline|
|
|
845
|
+
baseline["status"] == "FAILED"
|
|
846
|
+
end || counts.fetch("failed_tests").positive?
|
|
847
|
+
aggregate_status = if forced_error
|
|
848
|
+
"ERROR"
|
|
849
|
+
elsif forced_incomplete
|
|
850
|
+
"INCOMPLETE"
|
|
851
|
+
elsif forced_failed
|
|
852
|
+
"FAILED"
|
|
853
|
+
else
|
|
854
|
+
"PASSED"
|
|
855
|
+
end
|
|
856
|
+
fail_with("collation aggregate baseline status does not match provenance") unless
|
|
857
|
+
@document.dig("baseline", "status") == aggregate_status
|
|
858
|
+
fail_with("collation aggregate finalized does not match status") unless
|
|
859
|
+
@document.dig("baseline", "finalized") == %w[PASSED FAILED].include?(aggregate_status)
|
|
860
|
+
if status != "complete"
|
|
861
|
+
%w[completeness observations analysis].each do |section|
|
|
862
|
+
bits = if section == "completeness"
|
|
863
|
+
@document[section]
|
|
864
|
+
elsif section == "observations"
|
|
865
|
+
observations["completeness"]
|
|
866
|
+
else
|
|
867
|
+
@document.dig(section, "completeness")
|
|
868
|
+
end
|
|
869
|
+
if bits && bits["observation"] == true
|
|
870
|
+
fail_with("incomplete collection cannot claim observation completeness")
|
|
871
|
+
end
|
|
872
|
+
end
|
|
873
|
+
end
|
|
874
|
+
%w[observation attribution analysis].each do |field|
|
|
875
|
+
next unless shards.any? { |shard| shard.dig("completeness", field) == false }
|
|
876
|
+
|
|
877
|
+
completeness_restored = @document.dig("completeness", field) == true ||
|
|
878
|
+
@document.dig("observations", "completeness", field) == true ||
|
|
879
|
+
(@document["analysis"] && @document.dig("analysis", "completeness", field) == true)
|
|
880
|
+
fail_with("collation cannot restore false completeness") if completeness_restored
|
|
881
|
+
end
|
|
882
|
+
end
|
|
883
|
+
|
|
884
|
+
def validate_collated_test_statuses(observations)
|
|
885
|
+
test_statuses = lambda do |tests, section|
|
|
886
|
+
tests.each do |test|
|
|
887
|
+
status = test["status"]
|
|
888
|
+
unless %w[passed failed skipped unknown running].include?(status)
|
|
889
|
+
fail_with("collation #{section} test status is invalid")
|
|
890
|
+
end
|
|
891
|
+
end
|
|
892
|
+
end
|
|
893
|
+
observed_tests = observations.fetch("tests")
|
|
894
|
+
baseline_tests = @document.dig("baseline", "tests") || []
|
|
895
|
+
test_statuses.call(observed_tests, "observation")
|
|
896
|
+
test_statuses.call(baseline_tests, "baseline")
|
|
897
|
+
baseline_by_id = baseline_tests.to_h { |test| [test.fetch("id"), test] }
|
|
898
|
+
observed_tests.each do |test|
|
|
899
|
+
baseline_test = baseline_by_id[test.fetch("id")]
|
|
900
|
+
next unless baseline_test
|
|
901
|
+
next if baseline_test["status"] == test["status"]
|
|
902
|
+
|
|
903
|
+
fail_with("collation baseline and observation test statuses disagree")
|
|
904
|
+
end
|
|
905
|
+
failed = observed_tests.count { |test| test["status"] == "failed" }
|
|
906
|
+
skipped = observed_tests.count { |test| test["status"] == "skipped" }
|
|
907
|
+
baseline = @document.fetch("baseline")
|
|
908
|
+
if failed > baseline.fetch("failed_tests") || skipped > baseline.fetch("skipped_tests")
|
|
909
|
+
fail_with("collation observed failures or skips exceed baseline counts")
|
|
910
|
+
end
|
|
911
|
+
if baseline["status"] == "PASSED" && observed_tests.any? do |test|
|
|
912
|
+
!%w[passed skipped].include?(test["status"])
|
|
913
|
+
end
|
|
914
|
+
fail_with("PASSED collation cannot contain failed, unknown, or running tests")
|
|
915
|
+
end
|
|
916
|
+
end
|
|
917
|
+
|
|
918
|
+
def validate_changed_files(files)
|
|
919
|
+
seen_paths = {}
|
|
920
|
+
current_paths = []
|
|
921
|
+
files.each do |file|
|
|
922
|
+
fail_with("invalid changed file") unless hash_with_string_keys?(file)
|
|
923
|
+
status = file["status"]
|
|
924
|
+
simple_status = %w[A D M T U X B].include?(status)
|
|
925
|
+
scored_status = status.is_a?(String) && status.match?(/\A[RC]\d{1,3}\z/) &&
|
|
926
|
+
status[1..].to_i.between?(0, 100)
|
|
927
|
+
valid_status = simple_status || scored_status
|
|
928
|
+
fail_with("changed file status is invalid") unless valid_status
|
|
929
|
+
path = file["path"]
|
|
930
|
+
validate_changed_path(path, "changed file path")
|
|
931
|
+
fail_with("duplicate changed file path") if seen_paths.key?(path)
|
|
932
|
+
seen_paths[path] = true
|
|
933
|
+
rename = status.start_with?("R", "C")
|
|
934
|
+
old_path = file["old_path"]
|
|
935
|
+
if rename
|
|
936
|
+
validate_changed_path(old_path, "changed file old_path")
|
|
937
|
+
fail_with("rename paths must differ") if old_path == path
|
|
938
|
+
elsif file.key?("old_path")
|
|
939
|
+
fail_with("old_path is only valid for renamed files")
|
|
940
|
+
end
|
|
941
|
+
current_paths << path unless status == "D"
|
|
942
|
+
hunks = file["hunks"]
|
|
943
|
+
fail_with("changed file hunks must be an array") unless hunks.is_a?(Array)
|
|
944
|
+
hunks.each { |hunk| validate_changed_hunk(hunk) }
|
|
945
|
+
end
|
|
946
|
+
current_paths
|
|
947
|
+
end
|
|
948
|
+
|
|
949
|
+
def validate_changed_path(path, field)
|
|
950
|
+
unless path.is_a?(String) && path.encoding == Encoding::UTF_8 && path.valid_encoding? && !path.empty? &&
|
|
951
|
+
!path.include?("\0") && !path.start_with?("/") && !path.split("/").intersect?(%w[. ..])
|
|
952
|
+
fail_with("#{field} must be a safe UTF-8 relative path")
|
|
953
|
+
end
|
|
954
|
+
end
|
|
955
|
+
|
|
956
|
+
def validate_changed_hunk(hunk)
|
|
957
|
+
fail_with("invalid changed hunk") unless hash_with_string_keys?(hunk)
|
|
958
|
+
%w[old_start old_count new_start new_count].each do |field|
|
|
959
|
+
value = hunk[field]
|
|
960
|
+
fail_with("changed hunk #{field} must be a nonnegative integer") unless value.is_a?(Integer) && value >= 0
|
|
961
|
+
end
|
|
962
|
+
%w[old new].each do |side|
|
|
963
|
+
start = hunk["#{side}_start"]
|
|
964
|
+
count = hunk["#{side}_count"]
|
|
965
|
+
fail_with("changed hunk #{side} range has an invalid start") if count.positive? && start.zero?
|
|
966
|
+
end
|
|
967
|
+
end
|
|
968
|
+
|
|
969
|
+
def nonempty_string?(value)
|
|
970
|
+
value.is_a?(String) && !value.empty?
|
|
971
|
+
end
|
|
972
|
+
|
|
655
973
|
def validate_phases(vector)
|
|
656
974
|
phases = vector["phases_by_test"]
|
|
657
975
|
return if phases.nil?
|
data/lib/branchproof/source.rb
CHANGED
|
@@ -6,6 +6,7 @@ require "prism"
|
|
|
6
6
|
require_relative "decision_syntax"
|
|
7
7
|
require_relative "iteration_syntax"
|
|
8
8
|
require_relative "exception_syntax"
|
|
9
|
+
require_relative "fallback_syntax"
|
|
9
10
|
require_relative "default_syntax"
|
|
10
11
|
require_relative "value_syntax"
|
|
11
12
|
require_relative "constraints"
|
|
@@ -19,9 +20,19 @@ module Branchproof
|
|
|
19
20
|
# family. Additional modules can follow the same seam.
|
|
20
21
|
include IterationSyntax
|
|
21
22
|
include ExceptionSyntax
|
|
23
|
+
include FallbackSyntax
|
|
22
24
|
prepend DefaultSyntax
|
|
23
25
|
prepend ValueSyntax
|
|
24
26
|
|
|
27
|
+
# Strings, symbols, and numbers are always truthy. A regexp literal in a
|
|
28
|
+
# condition matches against $_, so its truth is not static.
|
|
29
|
+
ALWAYS_TRUTHY_LITERALS = [
|
|
30
|
+
Prism::TrueNode, Prism::StringNode, Prism::InterpolatedStringNode,
|
|
31
|
+
Prism::SymbolNode, Prism::InterpolatedSymbolNode, Prism::IntegerNode,
|
|
32
|
+
Prism::FloatNode, Prism::RationalNode, Prism::ImaginaryNode
|
|
33
|
+
].freeze
|
|
34
|
+
private_constant :ALWAYS_TRUTHY_LITERALS
|
|
35
|
+
|
|
25
36
|
attr_reader :root, :limits
|
|
26
37
|
|
|
27
38
|
def initialize(root:, limits:)
|
|
@@ -174,10 +185,17 @@ module Branchproof
|
|
|
174
185
|
|
|
175
186
|
# Boolean expressions nested in an atomic expression (for example, a call
|
|
176
187
|
# argument) are separate decisions when tree_for did not decompose them.
|
|
188
|
+
fallback_nodes = []
|
|
177
189
|
collected[:phase_two_nodes].each do |node|
|
|
178
190
|
next if inventoried_boolean_nodes[node]
|
|
179
191
|
next if within_defined_expression?(node, defined_ranges) && !node.is_a?(Prism::DefinedNode)
|
|
180
192
|
|
|
193
|
+
if fallback_chain?(node)
|
|
194
|
+
fallback_nodes << node
|
|
195
|
+
mark_semantic_boolean_nodes(node, inventoried_boolean_nodes)
|
|
196
|
+
next
|
|
197
|
+
end
|
|
198
|
+
|
|
181
199
|
context = if node.is_a?(Prism::MatchPredicateNode)
|
|
182
200
|
"pattern_in"
|
|
183
201
|
elsif node.is_a?(Prism::DefinedNode)
|
|
@@ -204,9 +222,12 @@ module Branchproof
|
|
|
204
222
|
predicate: predicate, context: spec[:context],
|
|
205
223
|
additional_reasons: spec[:additional_reasons] || [])
|
|
206
224
|
end
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
225
|
+
fallback_decisions = fallback_nodes.map do |node|
|
|
226
|
+
build_flow_decision(node, bytes, source_id, file_reasons, encoding)
|
|
227
|
+
end
|
|
228
|
+
decisions = boolean_decisions + fallback_decisions +
|
|
229
|
+
flow_decisions_for(program, bytes, source_id, file_reasons, encoding,
|
|
230
|
+
defined_ranges: defined_ranges, nodes: collected[:flow_nodes])
|
|
210
231
|
decisions + additional_decisions_for(program, bytes, source_id, file_reasons, encoding,
|
|
211
232
|
collected: collected, decisions: decisions)
|
|
212
233
|
end
|
|
@@ -410,7 +431,7 @@ module Branchproof
|
|
|
410
431
|
end
|
|
411
432
|
|
|
412
433
|
def literal_truth(node)
|
|
413
|
-
return true if node.is_a?(
|
|
434
|
+
return true if ALWAYS_TRUTHY_LITERALS.any? { |type| node.is_a?(type) }
|
|
414
435
|
return false if node.is_a?(Prism::FalseNode) || node.is_a?(Prism::NilNode)
|
|
415
436
|
|
|
416
437
|
nil
|