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.
Files changed (75) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +89 -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 +6 -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 +89 -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 +25 -4
  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 +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 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,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
- decisions = boolean_decisions + flow_decisions_for(program, bytes, source_id, file_reasons, encoding,
208
- defined_ranges: defined_ranges,
209
- 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])
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?(Prism::TrueNode)
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