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.
Files changed (75) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +82 -0
  3. data/README.md +440 -29
  4. data/doc/Branchproof/CLI.md +1 -1
  5. data/doc/Branchproof/ChangedCoverage.md +19 -0
  6. data/doc/Branchproof/ChangedCoveragePolicy.md +24 -0
  7. data/doc/Branchproof/ChangedDecisionMap.md +20 -0
  8. data/doc/Branchproof/ChangedScope.md +18 -0
  9. data/doc/Branchproof/Collation.md +23 -0
  10. data/doc/Branchproof/CollationManifest.md +29 -0
  11. data/doc/Branchproof/CoverageSummary.md +20 -0
  12. data/doc/Branchproof/Doctor/TerminalRenderer.md +15 -0
  13. data/doc/Branchproof/Doctor.md +34 -0
  14. data/doc/Branchproof/FallbackInstrumentation.md +8 -0
  15. data/doc/Branchproof/FallbackRuntime.md +12 -0
  16. data/doc/Branchproof/FallbackSyntax.md +13 -0
  17. data/doc/Branchproof/GitChanges.md +19 -0
  18. data/doc/Branchproof/GithubReport.md +25 -0
  19. data/doc/Branchproof/HtmlReport.md +22 -0
  20. data/doc/Branchproof/Instrumenter.md +1 -1
  21. data/doc/Branchproof/MinitestAdapter.md +4 -0
  22. data/doc/Branchproof/RakeTask.md +121 -0
  23. data/doc/Branchproof/Report.md +41 -3
  24. data/doc/Branchproof/ReportSelection.md +10 -1
  25. data/doc/Branchproof/Runtime.md +4 -1
  26. data/doc/Branchproof/Source.md +1 -1
  27. data/doc/Branchproof/SummaryRanking/Decision.md +86 -0
  28. data/doc/Branchproof/SummaryRanking/SourceFile.md +43 -0
  29. data/doc/Branchproof/SummaryRanking.md +36 -0
  30. data/doc/Branchproof/SummaryReport.md +42 -0
  31. data/doc/Branchproof/Worker.md +1 -1
  32. data/doc/Branchproof.md +21 -1
  33. data/doc/CHANGELOG.md +82 -0
  34. data/doc/README.md +440 -29
  35. data/lib/branchproof/analyzer.rb +2 -52
  36. data/lib/branchproof/changed_coverage.rb +177 -0
  37. data/lib/branchproof/changed_decision_map.rb +359 -0
  38. data/lib/branchproof/changed_scope.rb +93 -0
  39. data/lib/branchproof/cli.rb +242 -21
  40. data/lib/branchproof/collation.rb +525 -0
  41. data/lib/branchproof/collation_manifest.rb +129 -0
  42. data/lib/branchproof/comparison.rb +11 -3
  43. data/lib/branchproof/comparison_report.rb +2 -0
  44. data/lib/branchproof/configuration.rb +12 -2
  45. data/lib/branchproof/coverage_policy.rb +122 -0
  46. data/lib/branchproof/coverage_summary.rb +94 -0
  47. data/lib/branchproof/doctor.rb +241 -0
  48. data/lib/branchproof/evidence.rb +5 -1
  49. data/lib/branchproof/exception_instrumentation.rb +4 -3
  50. data/lib/branchproof/exception_syntax.rb +15 -1
  51. data/lib/branchproof/fallback_instrumentation.rb +25 -0
  52. data/lib/branchproof/fallback_runtime.rb +14 -0
  53. data/lib/branchproof/fallback_syntax.rb +90 -0
  54. data/lib/branchproof/focused_report.rb +81 -22
  55. data/lib/branchproof/git_changes.rb +188 -0
  56. data/lib/branchproof/github_report.rb +292 -0
  57. data/lib/branchproof/html_report.rb +412 -0
  58. data/lib/branchproof/instrumenter.rb +2 -0
  59. data/lib/branchproof/minimizer.rb +4 -2
  60. data/lib/branchproof/minitest_adapter.rb +45 -5
  61. data/lib/branchproof/rails_support.rb +2 -2
  62. data/lib/branchproof/rake_task.rb +186 -0
  63. data/lib/branchproof/report.rb +383 -71
  64. data/lib/branchproof/report_selection.rb +26 -5
  65. data/lib/branchproof/runtime.rb +2 -0
  66. data/lib/branchproof/saved_report.rb +323 -5
  67. data/lib/branchproof/source.rb +15 -3
  68. data/lib/branchproof/summary_ranking.rb +165 -0
  69. data/lib/branchproof/summary_report.rb +241 -0
  70. data/lib/branchproof/version.rb +1 -1
  71. data/lib/branchproof/worker.rb +14 -9
  72. data/lib/branchproof.rb +12 -0
  73. data/llms.txt +21 -1
  74. data/sig/branchproof.rbs +206 -5
  75. metadata +39 -23
@@ -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 focus_active?
57
+ return decisions unless decision_filter_active?
42
58
 
43
59
  sources = source_map(inventory)
44
- decisions.select { |decision| matches_decision?(decision, sources) }
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)
@@ -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
- missing = REQUIRED_FIELDS.reject { |field| @document.key?(field) }
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 @schema_version == "1.4" && !policy
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?
@@ -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,6 +20,7 @@ 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
 
@@ -183,10 +185,17 @@ module Branchproof
183
185
 
184
186
  # Boolean expressions nested in an atomic expression (for example, a call
185
187
  # argument) are separate decisions when tree_for did not decompose them.
188
+ fallback_nodes = []
186
189
  collected[:phase_two_nodes].each do |node|
187
190
  next if inventoried_boolean_nodes[node]
188
191
  next if within_defined_expression?(node, defined_ranges) && !node.is_a?(Prism::DefinedNode)
189
192
 
193
+ if fallback_chain?(node)
194
+ fallback_nodes << node
195
+ mark_semantic_boolean_nodes(node, inventoried_boolean_nodes)
196
+ next
197
+ end
198
+
190
199
  context = if node.is_a?(Prism::MatchPredicateNode)
191
200
  "pattern_in"
192
201
  elsif node.is_a?(Prism::DefinedNode)
@@ -213,9 +222,12 @@ module Branchproof
213
222
  predicate: predicate, context: spec[:context],
214
223
  additional_reasons: spec[:additional_reasons] || [])
215
224
  end
216
- decisions = boolean_decisions + flow_decisions_for(program, bytes, source_id, file_reasons, encoding,
217
- defined_ranges: defined_ranges,
218
- nodes: collected[:flow_nodes])
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])
219
231
  decisions + additional_decisions_for(program, bytes, source_id, file_reasons, encoding,
220
232
  collected: collected, decisions: decisions)
221
233
  end
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/ClassLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
4
+ require_relative "coverage_index"
5
+ require_relative "report_selection"
6
+
7
+ module Branchproof
8
+ # Ranks supported decisions and source files by missing coverage obligations.
9
+ #
10
+ # Order: unexecuted decisions first, then more missing obligations
11
+ # (decision-table rules, unproven MC/DC conditions, missing alternatives),
12
+ # then source path, line, column, and ID. The order is a count, not a risk
13
+ # estimate. Unsupported decisions are counted but never ranked.
14
+ class SummaryRanking
15
+ Decision = Struct.new(:id, :relative_path, :line, :column, :expression, :kind, :context, :unexecuted,
16
+ :missing_rules, :required_rules, :table_calculated, :unproven_conditions,
17
+ :conditions, :missing_alternatives, :alternatives, :test_ids,
18
+ keyword_init: true) do
19
+ def missing_total = missing_rules.length + unproven_conditions.length + missing_alternatives.length
20
+ def gap? = unexecuted || missing_total.positive?
21
+
22
+ # One new observation covers at most one missing rule, so missing rules
23
+ # are an exact case count. Without missing rules (no table, or only
24
+ # impossible rules left), each unproven condition or alternative counts.
25
+ def cases_to_test
26
+ return missing_rules.length if rule_cases?
27
+
28
+ unproven_conditions.length + missing_alternatives.length
29
+ end
30
+
31
+ def rule_cases? = table_calculated && !missing_rules.empty?
32
+ end
33
+
34
+ SourceFile = Struct.new(:relative_path, :decisions, keyword_init: true) do
35
+ def gaps = decisions.count(&:gap?)
36
+ def unexecuted = decisions.count(&:unexecuted)
37
+ def missing_rules = decisions.sum { |decision| decision.missing_rules.length }
38
+ def required_rules = decisions.sum(&:required_rules)
39
+ def unproven_conditions = decisions.sum { |decision| decision.unproven_conditions.length }
40
+ def conditions = decisions.sum { |decision| decision.conditions.length }
41
+ def missing_alternatives = decisions.sum { |decision| decision.missing_alternatives.length }
42
+ def alternatives = decisions.sum { |decision| decision.alternatives.length }
43
+ def missing_total = decisions.sum(&:missing_total)
44
+ end
45
+
46
+ attr_reader :index
47
+
48
+ def initialize(document:, selection: ReportSelection.new)
49
+ @document = document || {}
50
+ @selection = selection
51
+ @index = CoverageIndex.new(document: @document)
52
+ end
53
+
54
+ def available?
55
+ analysis = fetch(@document, :analysis)
56
+ analysis.respond_to?(:key?) && !fetch(analysis, :decisions).nil?
57
+ end
58
+
59
+ # All supported decisions in the selection, ranked.
60
+ def decisions
61
+ @decisions ||= build_decisions.sort_by { |decision| decision_key(decision) }.freeze
62
+ end
63
+
64
+ def gaps
65
+ decisions.select(&:gap?)
66
+ end
67
+
68
+ def files
69
+ @files ||= begin
70
+ files = decisions.group_by(&:relative_path).map do |path, items|
71
+ SourceFile.new(relative_path: path, decisions: items)
72
+ end
73
+ files.sort_by { |file| file_key(file) }.freeze
74
+ end
75
+ end
76
+
77
+ def unsupported_count
78
+ inventory_decisions.count { |decision| fetch(decision, :support_status).to_s == "UNSUPPORTED" }
79
+ end
80
+
81
+ private
82
+
83
+ def build_decisions
84
+ conditions = @index.conditions.group_by { |row| row[:decision_id].to_s }
85
+ alternatives = @index.alternatives.group_by { |row| row[:decision_id].to_s }
86
+ tables = @index.decision_tables.to_h { |row| [row[:decision_id].to_s, row] }
87
+ sources = inventory_sources
88
+ selected_ids = @selection.decision_filter_active? ? @selection.selected_decision_ids(@document) : nil
89
+ selected_id_set = selected_ids&.to_h { |id| [id, true] }
90
+ inventory_decisions.filter_map do |decision|
91
+ id = fetch(decision, :id).to_s
92
+ next if fetch(decision, :support_status).to_s == "UNSUPPORTED"
93
+ next if selected_id_set && !selected_id_set.key?(id)
94
+
95
+ condition_rows = conditions.fetch(id, [])
96
+ alternative_rows = alternatives.fetch(id, [])
97
+ table = tables[id]
98
+ calculated = table && table[:status].to_s == "calculated"
99
+ source = sources[fetch(decision, :source_id).to_s] || {}
100
+ Decision.new(
101
+ id: id, relative_path: display_path(fetch(source, :relative_path)),
102
+ line: fetch(decision, :line), column: fetch(decision, :column),
103
+ expression: fetch(decision, :expression).to_s, kind: kind(decision), context: fetch(decision, :context),
104
+ unexecuted: !executed_decision_ids.include?(id),
105
+ missing_rules: calculated ? table[:rules].select { |rule| rule[:coverage].to_s == "missing" } : [],
106
+ required_rules: calculated ? table[:required_rules].to_i : 0, table_calculated: calculated ? true : false,
107
+ unproven_conditions: condition_rows.reject { |row| row[:status].to_s.upcase == "PROVEN" },
108
+ conditions: condition_rows,
109
+ missing_alternatives: alternative_rows.select { |row| row[:missing] }, alternatives: alternative_rows,
110
+ test_ids: reaching_test_ids(condition_rows, alternative_rows)
111
+ )
112
+ end
113
+ end
114
+
115
+ def reaching_test_ids(condition_rows, alternative_rows)
116
+ ids = condition_rows.flat_map { |row| row[:observed_true] + row[:observed_false] + row[:short_circuited] }
117
+ ids += alternative_rows.flat_map do |row|
118
+ %i[selected not_selected skipped].flat_map { |state| Array((row[state] || {})[:test_ids]) }
119
+ end
120
+ ids.map(&:to_s).uniq.sort
121
+ end
122
+
123
+ def decision_key(decision)
124
+ [decision.unexecuted ? 0 : 1, -decision.missing_total, decision.relative_path.to_s,
125
+ decision.line.to_i, decision.column.to_i, decision.id]
126
+ end
127
+
128
+ def file_key(file)
129
+ [-file.unexecuted, -file.missing_total, file.relative_path.to_s]
130
+ end
131
+
132
+ def executed_decision_ids
133
+ @executed_decision_ids ||= Array(fetch(fetch(@document, :observations) || {}, :vectors))
134
+ .to_set { |vector| fetch(vector, :decision_id).to_s }
135
+ end
136
+
137
+ def kind(decision)
138
+ value = fetch(decision, :kind).to_s
139
+ value.empty? ? "boolean" : value
140
+ end
141
+
142
+ def display_path(path)
143
+ return nil if path.to_s.empty? || Pathname.new(path.to_s).absolute?
144
+
145
+ path.to_s
146
+ end
147
+
148
+ def inventory
149
+ fetch(@document, :source_inventory) || fetch(@document, :inventory) || {}
150
+ end
151
+
152
+ def inventory_decisions = Array(fetch(inventory, :decisions))
153
+
154
+ def inventory_sources
155
+ Array(fetch(inventory, :source_units)).to_h { |unit| [fetch(unit, :source_id).to_s, unit] }
156
+ end
157
+
158
+ def fetch(hash, key)
159
+ return nil unless hash.respond_to?(:key?)
160
+
161
+ hash.key?(key) ? hash[key] : hash[key.to_s]
162
+ end
163
+ end
164
+ end
165
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength, Metrics/ClassLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity