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