branchproof 0.11.1 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +82 -0
- data/README.md +440 -29
- data/doc/Branchproof/CLI.md +1 -1
- data/doc/Branchproof/ChangedCoverage.md +19 -0
- data/doc/Branchproof/ChangedCoveragePolicy.md +24 -0
- data/doc/Branchproof/ChangedDecisionMap.md +20 -0
- data/doc/Branchproof/ChangedScope.md +18 -0
- data/doc/Branchproof/Collation.md +23 -0
- data/doc/Branchproof/CollationManifest.md +29 -0
- data/doc/Branchproof/CoverageSummary.md +20 -0
- data/doc/Branchproof/Doctor/TerminalRenderer.md +15 -0
- data/doc/Branchproof/Doctor.md +34 -0
- data/doc/Branchproof/FallbackInstrumentation.md +8 -0
- data/doc/Branchproof/FallbackRuntime.md +12 -0
- data/doc/Branchproof/FallbackSyntax.md +13 -0
- data/doc/Branchproof/GitChanges.md +19 -0
- data/doc/Branchproof/GithubReport.md +25 -0
- data/doc/Branchproof/HtmlReport.md +22 -0
- data/doc/Branchproof/Instrumenter.md +1 -1
- data/doc/Branchproof/MinitestAdapter.md +4 -0
- data/doc/Branchproof/RakeTask.md +121 -0
- data/doc/Branchproof/Report.md +41 -3
- data/doc/Branchproof/ReportSelection.md +10 -1
- data/doc/Branchproof/Runtime.md +4 -1
- data/doc/Branchproof/Source.md +1 -1
- data/doc/Branchproof/SummaryRanking/Decision.md +86 -0
- data/doc/Branchproof/SummaryRanking/SourceFile.md +43 -0
- data/doc/Branchproof/SummaryRanking.md +36 -0
- data/doc/Branchproof/SummaryReport.md +42 -0
- data/doc/Branchproof/Worker.md +1 -1
- data/doc/Branchproof.md +21 -1
- data/doc/CHANGELOG.md +82 -0
- data/doc/README.md +440 -29
- data/lib/branchproof/analyzer.rb +2 -52
- data/lib/branchproof/changed_coverage.rb +177 -0
- data/lib/branchproof/changed_decision_map.rb +359 -0
- data/lib/branchproof/changed_scope.rb +93 -0
- data/lib/branchproof/cli.rb +242 -21
- data/lib/branchproof/collation.rb +525 -0
- data/lib/branchproof/collation_manifest.rb +129 -0
- data/lib/branchproof/comparison.rb +11 -3
- data/lib/branchproof/comparison_report.rb +2 -0
- data/lib/branchproof/configuration.rb +12 -2
- data/lib/branchproof/coverage_policy.rb +122 -0
- data/lib/branchproof/coverage_summary.rb +94 -0
- data/lib/branchproof/doctor.rb +241 -0
- data/lib/branchproof/evidence.rb +5 -1
- data/lib/branchproof/exception_instrumentation.rb +4 -3
- data/lib/branchproof/exception_syntax.rb +15 -1
- data/lib/branchproof/fallback_instrumentation.rb +25 -0
- data/lib/branchproof/fallback_runtime.rb +14 -0
- data/lib/branchproof/fallback_syntax.rb +90 -0
- data/lib/branchproof/focused_report.rb +81 -22
- data/lib/branchproof/git_changes.rb +188 -0
- data/lib/branchproof/github_report.rb +292 -0
- data/lib/branchproof/html_report.rb +412 -0
- data/lib/branchproof/instrumenter.rb +2 -0
- data/lib/branchproof/minimizer.rb +4 -2
- data/lib/branchproof/minitest_adapter.rb +45 -5
- data/lib/branchproof/rails_support.rb +2 -2
- data/lib/branchproof/rake_task.rb +186 -0
- data/lib/branchproof/report.rb +383 -71
- data/lib/branchproof/report_selection.rb +26 -5
- data/lib/branchproof/runtime.rb +2 -0
- data/lib/branchproof/saved_report.rb +323 -5
- data/lib/branchproof/source.rb +15 -3
- data/lib/branchproof/summary_ranking.rb +165 -0
- data/lib/branchproof/summary_report.rb +241 -0
- data/lib/branchproof/version.rb +1 -1
- data/lib/branchproof/worker.rb +14 -9
- data/lib/branchproof.rb +12 -0
- data/llms.txt +21 -1
- data/sig/branchproof.rbs +206 -5
- metadata +39 -23
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "coverage_summary"
|
|
4
|
+
|
|
5
|
+
# rubocop:disable-next Metrics/ModuleLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
6
|
+
module Branchproof
|
|
7
|
+
# Computes informational coverage for the current decisions captured by a changed scope.
|
|
8
|
+
module ChangedCoverage
|
|
9
|
+
CRITERIA = {
|
|
10
|
+
decision: %i[covered_decisions supported_decisions],
|
|
11
|
+
condition: %i[covered_values required_values],
|
|
12
|
+
condition_decision: %i[covered_decisions supported_decisions],
|
|
13
|
+
mcdc: %i[proven_conditions supported_conditions],
|
|
14
|
+
decision_table: %i[covered_rules required_rules],
|
|
15
|
+
alternative: %i[covered_alternatives required_alternatives]
|
|
16
|
+
}.freeze
|
|
17
|
+
COMPLETENESS_FIELDS = %i[observation attribution analysis].freeze
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
def call(document:, scope:)
|
|
22
|
+
ids = Array(value(scope, :decision_ids))
|
|
23
|
+
scope_status = value(scope, :status)
|
|
24
|
+
decisions = analysis_decisions(document)
|
|
25
|
+
by_id = decisions.to_h { |decision| [value(decision, :decision_id), decision] }
|
|
26
|
+
selected = ids.filter_map { |id| by_id[id] }
|
|
27
|
+
supported_count = selected.count { |decision| !value(decision, :unsupported) }
|
|
28
|
+
unsupported_count = selected.length - supported_count
|
|
29
|
+
reason = unavailable_reason(document, scope, ids, selected)
|
|
30
|
+
usable = selected.select do |decision|
|
|
31
|
+
value(decision, :unsupported) || complete_decision_coverage?(decision)
|
|
32
|
+
end
|
|
33
|
+
summary = CoverageSummary.call(usable)
|
|
34
|
+
|
|
35
|
+
{
|
|
36
|
+
scope_status: scope_status,
|
|
37
|
+
status: reason ? "unavailable" : "available",
|
|
38
|
+
reason: reason,
|
|
39
|
+
selected_decisions: ids.length,
|
|
40
|
+
supported_decisions: supported_count,
|
|
41
|
+
unsupported_decisions: unsupported_count,
|
|
42
|
+
coverage: coverage_rows(summary, reason)
|
|
43
|
+
}
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def unavailable_reason(document, scope, ids, selected)
|
|
47
|
+
return "invalid_scope" unless scope.is_a?(Hash) && %w[complete empty].include?(value(scope, :status))
|
|
48
|
+
return "invalid_scope" if (value(scope, :status) == "empty") != ids.empty?
|
|
49
|
+
return "empty_scope" if ids.empty?
|
|
50
|
+
return "missing_selected_analysis" unless selected.length == ids.length
|
|
51
|
+
return "unsupported_only" if selected.all? { |decision| value(decision, :unsupported) }
|
|
52
|
+
return "missing_selected_coverage" unless selected.all? do |decision|
|
|
53
|
+
value(decision, :unsupported) || complete_decision_coverage?(decision)
|
|
54
|
+
end
|
|
55
|
+
return "baseline_unavailable" unless successful_baseline?(document)
|
|
56
|
+
return "incomplete_document" unless complete?(value(document, :completeness))
|
|
57
|
+
|
|
58
|
+
observations = value(document, :observations) || value(document, :evidence)
|
|
59
|
+
return "incomplete_observations" if incomplete_section?(observations)
|
|
60
|
+
|
|
61
|
+
analysis = value(document, :analysis)
|
|
62
|
+
return "analysis_unavailable" unless analysis.is_a?(Hash)
|
|
63
|
+
return "incomplete_analysis" if incomplete_section?(analysis)
|
|
64
|
+
|
|
65
|
+
nil
|
|
66
|
+
end
|
|
67
|
+
private_class_method :unavailable_reason
|
|
68
|
+
|
|
69
|
+
def complete_decision_coverage?(decision)
|
|
70
|
+
coverage = value(decision, :coverage)
|
|
71
|
+
return false unless coverage.is_a?(Hash)
|
|
72
|
+
|
|
73
|
+
if alternative_decision?(decision)
|
|
74
|
+
alternative = value(coverage, :alternative)
|
|
75
|
+
mcdc = value(coverage, :mcdc)
|
|
76
|
+
return false unless alternative.is_a?(Hash) && integer_count?(value(alternative, :covered_alternatives)) &&
|
|
77
|
+
integer_count?(value(alternative, :required_alternatives)) &&
|
|
78
|
+
value(mcdc, :status) == "not_applicable"
|
|
79
|
+
|
|
80
|
+
value(alternative, :covered_alternatives) <= value(alternative, :required_alternatives)
|
|
81
|
+
else
|
|
82
|
+
rows = %i[decision condition condition_decision mcdc decision_table].map { |key| value(coverage, key) }
|
|
83
|
+
return false unless rows.all?(Hash)
|
|
84
|
+
return false unless value(value(coverage, :decision), :status).is_a?(String)
|
|
85
|
+
return false unless value(value(coverage, :condition_decision), :status).is_a?(String)
|
|
86
|
+
return false unless %i[covered_values condition_count covered_conditions].all? do |key|
|
|
87
|
+
integer_count?(value(value(coverage, :condition), key))
|
|
88
|
+
end
|
|
89
|
+
return false unless integer_count?(value(value(coverage, :mcdc), :proven_conditions))
|
|
90
|
+
|
|
91
|
+
condition_count = value(value(coverage, :condition), :condition_count)
|
|
92
|
+
return false if value(value(coverage, :condition), :covered_values) > condition_count * 2
|
|
93
|
+
return false if value(value(coverage, :condition), :covered_conditions) > condition_count
|
|
94
|
+
return false if value(value(coverage, :mcdc), :proven_conditions) > condition_count
|
|
95
|
+
return false unless value(value(coverage, :decision_table), :status).is_a?(String)
|
|
96
|
+
|
|
97
|
+
table = value(decision, :decision_table)
|
|
98
|
+
return false unless table.is_a?(Hash) && %w[calculated not_calculated].include?(value(table, :status))
|
|
99
|
+
return true if value(table, :status) == "not_calculated"
|
|
100
|
+
|
|
101
|
+
valid_counts = %i[covered_rules required_rules generated_rules impossible_rules].all? do |key|
|
|
102
|
+
integer_count?(value(table, key))
|
|
103
|
+
end
|
|
104
|
+
return false unless valid_counts
|
|
105
|
+
|
|
106
|
+
covered = value(table, :covered_rules)
|
|
107
|
+
required = value(table, :required_rules)
|
|
108
|
+
generated = value(table, :generated_rules)
|
|
109
|
+
impossible = value(table, :impossible_rules)
|
|
110
|
+
covered <= required && required + impossible == generated && value(table, :coverage_status).is_a?(String)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
private_class_method :complete_decision_coverage?
|
|
114
|
+
|
|
115
|
+
def alternative_decision?(decision)
|
|
116
|
+
kind = value(decision, :kind).to_s
|
|
117
|
+
!kind.empty? && kind != "boolean"
|
|
118
|
+
end
|
|
119
|
+
private_class_method :alternative_decision?
|
|
120
|
+
|
|
121
|
+
def integer_count?(value)
|
|
122
|
+
value.is_a?(Integer) && value >= 0
|
|
123
|
+
end
|
|
124
|
+
private_class_method :integer_count?
|
|
125
|
+
|
|
126
|
+
def coverage_rows(summary, overall_reason)
|
|
127
|
+
CRITERIA.to_h do |criterion, (numerator_key, denominator_key)|
|
|
128
|
+
aggregate = value(summary, criterion) || {}
|
|
129
|
+
numerator = value(aggregate, numerator_key)
|
|
130
|
+
denominator = value(aggregate, denominator_key)
|
|
131
|
+
reason = overall_reason
|
|
132
|
+
reason ||= "decision_table_not_calculated" if criterion == :decision_table &&
|
|
133
|
+
value(aggregate, :not_calculated_decisions).to_i.positive?
|
|
134
|
+
reason ||= "zero_denominator" if denominator.to_i.zero?
|
|
135
|
+
[criterion, { numerator: numerator, denominator: denominator,
|
|
136
|
+
percentage: reason ? nil : value(aggregate, :percentage),
|
|
137
|
+
status: reason ? "unavailable" : "available", reason: reason }]
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
private_class_method :coverage_rows
|
|
141
|
+
|
|
142
|
+
def successful_baseline?(document)
|
|
143
|
+
baseline = value(document, :baseline)
|
|
144
|
+
value(baseline, :status).to_s.upcase == "PASSED" && value(baseline, :finalized) == true
|
|
145
|
+
end
|
|
146
|
+
private_class_method :successful_baseline?
|
|
147
|
+
|
|
148
|
+
def complete?(completeness)
|
|
149
|
+
completeness.is_a?(Hash) && COMPLETENESS_FIELDS.all? { |field| value(completeness, field) == true }
|
|
150
|
+
end
|
|
151
|
+
private_class_method :complete?
|
|
152
|
+
|
|
153
|
+
def incomplete_section?(section)
|
|
154
|
+
!section.is_a?(Hash) || !key_present?(section, :completeness) || !complete?(value(section, :completeness))
|
|
155
|
+
end
|
|
156
|
+
private_class_method :incomplete_section?
|
|
157
|
+
|
|
158
|
+
def analysis_decisions(document)
|
|
159
|
+
Array(value(value(document, :analysis), :decisions))
|
|
160
|
+
end
|
|
161
|
+
private_class_method :analysis_decisions
|
|
162
|
+
|
|
163
|
+
def value(hash, key)
|
|
164
|
+
return nil unless hash.respond_to?(:key?)
|
|
165
|
+
return hash[key] if hash.key?(key)
|
|
166
|
+
|
|
167
|
+
alternate = key.is_a?(Symbol) ? key.to_s : key.to_sym
|
|
168
|
+
hash[alternate] if hash.key?(alternate)
|
|
169
|
+
end
|
|
170
|
+
private_class_method :value
|
|
171
|
+
|
|
172
|
+
def key_present?(hash, key)
|
|
173
|
+
hash.key?(key) || hash.key?(key.to_s) || hash.key?(key.to_sym)
|
|
174
|
+
end
|
|
175
|
+
private_class_method :key_present?
|
|
176
|
+
end
|
|
177
|
+
end
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module Branchproof
|
|
6
|
+
# Maps changed lines to current decision records using Prism byte locations.
|
|
7
|
+
# rubocop:disable-next Metrics/ClassLength -- AST ownership rules share the parsed current source and hunk ranges.
|
|
8
|
+
class ChangedDecisionMap
|
|
9
|
+
CONTROL_NODES = [Prism::IfNode, Prism::UnlessNode, Prism::WhileNode, Prism::UntilNode].freeze
|
|
10
|
+
|
|
11
|
+
# rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity -- Keeps one Prism walk across all supported contexts.
|
|
12
|
+
def call(bytes:, path:, decisions:, hunks:, old_bytes: "")
|
|
13
|
+
parsed = Prism.parse(bytes)
|
|
14
|
+
unless parsed.errors.empty?
|
|
15
|
+
raise ArgumentError,
|
|
16
|
+
"cannot map changes in #{path.inspect}: current Ruby source does not parse"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
return [] if decisions.empty?
|
|
20
|
+
|
|
21
|
+
changed = changed_locations(hunks, old_bytes, bytes)
|
|
22
|
+
return [] if changed.empty?
|
|
23
|
+
|
|
24
|
+
selected = []
|
|
25
|
+
old_body_owners = deleted_old_body_owners(old_bytes, bytes, hunks, changed)
|
|
26
|
+
decisions.each do |decision|
|
|
27
|
+
expression = { byte_start: decision[:byte_start],
|
|
28
|
+
byte_length: decision[:byte_length] }
|
|
29
|
+
selected << decision[:id] if range_touched?(expression, changed)
|
|
30
|
+
instrumentation_range = decision.dig(:instrumentation, :range)
|
|
31
|
+
selected << decision[:id] if instrumentation_range && range_touched?(instrumentation_range, changed)
|
|
32
|
+
end
|
|
33
|
+
# rubocop:disable-next Metrics/BlockLength -- One AST walk keeps ownership mapping consistent across node kinds.
|
|
34
|
+
walk(parsed.value) do |node|
|
|
35
|
+
if CONTROL_NODES.any? { |type| node.is_a?(type) }
|
|
36
|
+
predicate = node.predicate
|
|
37
|
+
next unless predicate
|
|
38
|
+
|
|
39
|
+
predicate = unwrap_predicate(predicate)
|
|
40
|
+
record = decisions.find do |decision|
|
|
41
|
+
decision[:kind] == "boolean" && decision[:byte_start] == predicate.location.start_offset &&
|
|
42
|
+
decision[:byte_length] == predicate.location.length
|
|
43
|
+
end
|
|
44
|
+
next unless record
|
|
45
|
+
|
|
46
|
+
selected << record[:id] if range_touched?(predicate.location, changed)
|
|
47
|
+
selected << record[:id] if range_touched?(node.location, changed)
|
|
48
|
+
elsif node.is_a?(Prism::CaseNode) && node.predicate.nil?
|
|
49
|
+
case_records = []
|
|
50
|
+
node.conditions.each do |branch|
|
|
51
|
+
branch.conditions.each do |condition|
|
|
52
|
+
records = decisions.select do |decision|
|
|
53
|
+
decision[:context] == "case_when" && decision[:byte_start] == condition.location.start_offset &&
|
|
54
|
+
decision[:byte_length] == condition.location.length
|
|
55
|
+
end
|
|
56
|
+
case_records.concat(records)
|
|
57
|
+
next unless branch_body_touched?(branch, changed) ||
|
|
58
|
+
subjectless_body_deleted?(node, branch, old_body_owners)
|
|
59
|
+
|
|
60
|
+
selected.concat(records.map { |record| record[:id] })
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
else_clause = node.else_clause
|
|
64
|
+
else_touched = else_clause && (branch_body_touched?(else_clause, changed) ||
|
|
65
|
+
old_body_owners.include?([:else, node.location.start_offset]))
|
|
66
|
+
selected.concat(case_records.map { |record| record[:id] }) if else_touched
|
|
67
|
+
elsif node.is_a?(Prism::CaseNode) || node.is_a?(Prism::CaseMatchNode)
|
|
68
|
+
record = decisions.find do |decision|
|
|
69
|
+
decision[:context] == (node.is_a?(Prism::CaseMatchNode) ? "case_in" : "case") &&
|
|
70
|
+
decision.dig(:instrumentation, :range, :byte_start) == node.location.start_offset
|
|
71
|
+
end
|
|
72
|
+
selected << record[:id] if record && body_touched?(node, changed)
|
|
73
|
+
elsif node.is_a?(Prism::InNode) && (node.pattern.is_a?(Prism::IfNode) || node.pattern.is_a?(Prism::UnlessNode))
|
|
74
|
+
guard = unwrap_predicate(node.pattern.predicate)
|
|
75
|
+
record = decisions.find do |decision|
|
|
76
|
+
decision[:context] == "pattern_guard" && decision[:byte_start] == guard.location.start_offset &&
|
|
77
|
+
decision[:byte_length] == guard.location.length
|
|
78
|
+
end
|
|
79
|
+
body_touched = branch_body_touched?(node, changed) ||
|
|
80
|
+
old_body_owners.include?([:in, node.location.start_offset,
|
|
81
|
+
guard.location.start_offset, guard.location.length])
|
|
82
|
+
selected << record[:id] if record && body_touched
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
selected.uniq
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
def unwrap_predicate(node)
|
|
92
|
+
while node.is_a?(Prism::ParenthesesNode)
|
|
93
|
+
body = node.body&.body
|
|
94
|
+
return node unless body&.length == 1
|
|
95
|
+
|
|
96
|
+
node = body.first
|
|
97
|
+
end
|
|
98
|
+
node
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity -- Resolves deleted tokens only to surviving branch owners.
|
|
102
|
+
# rubocop:disable-next Metrics/BlockLength -- Old AST ownership is translated against the same hunk set.
|
|
103
|
+
def deleted_old_body_owners(old_bytes, current_bytes, hunks, changed)
|
|
104
|
+
return [] if old_bytes.empty? || changed.none? { |item| item[:old_token_start] }
|
|
105
|
+
|
|
106
|
+
parsed = Prism.parse(old_bytes)
|
|
107
|
+
return [] unless parsed.errors.empty?
|
|
108
|
+
|
|
109
|
+
owners = []
|
|
110
|
+
walk(parsed.value) do |node|
|
|
111
|
+
if node.is_a?(Prism::CaseNode) && node.predicate.nil?
|
|
112
|
+
branches = node.conditions
|
|
113
|
+
branches.each_with_index do |branch, index|
|
|
114
|
+
next_branch = branches[index + 1]
|
|
115
|
+
boundary = next_branch && branch_keyword_location(next_branch)&.start_offset
|
|
116
|
+
boundary ||= node.else_clause&.else_keyword_loc&.start_offset
|
|
117
|
+
boundary ||= node.end_keyword_loc&.start_offset || node.location.end_offset
|
|
118
|
+
next unless old_branch_body_touched?(branch, boundary, changed)
|
|
119
|
+
|
|
120
|
+
current_case_start = map_old_offset(node.location.start_offset, old_bytes, current_bytes, hunks)
|
|
121
|
+
branch.conditions.each do |condition|
|
|
122
|
+
current_condition_start = map_old_offset(condition.location.start_offset, old_bytes, current_bytes, hunks)
|
|
123
|
+
next unless current_case_start && current_condition_start
|
|
124
|
+
|
|
125
|
+
owners << [:when, current_case_start, current_condition_start, condition.location.length]
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
else_boundary = node.end_keyword_loc&.start_offset || node.location.end_offset
|
|
129
|
+
if node.else_clause && old_else_body_touched?(node.else_clause, else_boundary, changed)
|
|
130
|
+
current_case_start = map_old_offset(node.location.start_offset, old_bytes, current_bytes, hunks)
|
|
131
|
+
owners << [:else, current_case_start] if current_case_start
|
|
132
|
+
end
|
|
133
|
+
elsif node.is_a?(Prism::CaseMatchNode)
|
|
134
|
+
branches = node.conditions
|
|
135
|
+
branches.each_with_index do |branch, index|
|
|
136
|
+
pattern = branch.pattern
|
|
137
|
+
next unless pattern.is_a?(Prism::IfNode) || pattern.is_a?(Prism::UnlessNode)
|
|
138
|
+
|
|
139
|
+
next_branch = branches[index + 1]
|
|
140
|
+
boundary = next_branch && branch_keyword_location(next_branch)&.start_offset
|
|
141
|
+
boundary ||= node.else_clause&.else_keyword_loc&.start_offset
|
|
142
|
+
boundary ||= node.end_keyword_loc&.start_offset || node.location.end_offset
|
|
143
|
+
next unless old_branch_body_touched?(branch, boundary, changed)
|
|
144
|
+
|
|
145
|
+
guard = unwrap_predicate(pattern.predicate)
|
|
146
|
+
current_branch_start = map_old_offset(branch.location.start_offset, old_bytes, current_bytes, hunks)
|
|
147
|
+
current_guard_start = map_old_offset(guard.location.start_offset, old_bytes, current_bytes, hunks)
|
|
148
|
+
next unless current_branch_start && current_guard_start
|
|
149
|
+
|
|
150
|
+
owners << [:in, current_branch_start, current_guard_start, guard.location.length]
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
owners
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def branch_keyword_location(branch)
|
|
158
|
+
branch.is_a?(Prism::InNode) ? branch.in_loc : branch.keyword_loc
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity -- Translates unchanged owner byte positions across line hunks.
|
|
162
|
+
def map_old_offset(offset, old_bytes, current_bytes, hunks)
|
|
163
|
+
old_line_start = line_offset(old_bytes, offset)
|
|
164
|
+
old_line = old_bytes.b.byteslice(0, offset).count("\n".b) + 1
|
|
165
|
+
line_delta = 0
|
|
166
|
+
Array(hunks).each do |hunk|
|
|
167
|
+
first = hunk[:old_start]
|
|
168
|
+
last = first + hunk[:old_count]
|
|
169
|
+
if hunk[:old_count].zero?
|
|
170
|
+
line_delta += hunk[:new_count] if old_line > first
|
|
171
|
+
next
|
|
172
|
+
end
|
|
173
|
+
return nil if hunk[:old_count].positive? && old_line >= first && old_line < last
|
|
174
|
+
|
|
175
|
+
line_delta += hunk[:new_count] - hunk[:old_count] if old_line >= last
|
|
176
|
+
end
|
|
177
|
+
current_line = old_line + line_delta
|
|
178
|
+
current_line_start = offset_after_line(current_bytes, current_line - 1)
|
|
179
|
+
column = offset - old_line_start
|
|
180
|
+
return nil if current_line_start + column > current_bytes.bytesize
|
|
181
|
+
|
|
182
|
+
current_line_start + column
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def line_offset(bytes, offset)
|
|
186
|
+
newline = bytes.b.byteslice(0, offset).rindex("\n".b)
|
|
187
|
+
newline ? newline + 1 : 0
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# rubocop:disable-next Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- Checks token ownership within one old branch body.
|
|
191
|
+
def old_branch_body_touched?(branch, boundary, changed)
|
|
192
|
+
return false unless boundary
|
|
193
|
+
|
|
194
|
+
body_start = branch.statements&.location&.start_offset || branch.location.end_offset
|
|
195
|
+
body_end = [branch.location.end_offset, boundary].min
|
|
196
|
+
return false unless body_end > body_start
|
|
197
|
+
|
|
198
|
+
changed.any? do |item|
|
|
199
|
+
item[:old_token_start] && item[:old_token_start] >= body_start && item[:old_token_start] < body_end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def old_else_body_touched?(branch, boundary, changed)
|
|
204
|
+
body_start = branch.else_keyword_loc.end_offset
|
|
205
|
+
changed.any? do |item|
|
|
206
|
+
item[:old_token_start] && item[:old_token_start] >= body_start && item[:old_token_start] < boundary
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def subjectless_body_deleted?(case_node, branch, old_body_owners)
|
|
211
|
+
branch.conditions.any? do |condition|
|
|
212
|
+
old_body_owners.include?([:when, case_node.location.start_offset,
|
|
213
|
+
condition.location.start_offset, condition.location.length])
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity -- Token comparison retains lexer context for heredocs and strings.
|
|
218
|
+
def changed_locations(hunks, old_bytes, bytes)
|
|
219
|
+
changed = []
|
|
220
|
+
old_tokens = Prism.lex(old_bytes).value
|
|
221
|
+
current_tokens = Prism.lex(bytes).value
|
|
222
|
+
Array(hunks).each do |hunk|
|
|
223
|
+
old_range = (hunk[:old_start]...(hunk[:old_start] + hunk[:old_count]))
|
|
224
|
+
new_range = (hunk[:new_start]...(hunk[:new_start] + hunk[:new_count]))
|
|
225
|
+
old_hunk_tokens = semantic_tokens(old_tokens, old_range)
|
|
226
|
+
new_hunk_tokens = semantic_tokens(current_tokens, new_range)
|
|
227
|
+
next unless meaningful_tokens?(old_hunk_tokens) || meaningful_tokens?(new_hunk_tokens)
|
|
228
|
+
next if old_hunk_tokens == new_hunk_tokens
|
|
229
|
+
|
|
230
|
+
count = hunk[:new_count]
|
|
231
|
+
start = hunk[:new_start]
|
|
232
|
+
old_token_start = first_meaningful_token_start(old_tokens, old_range)
|
|
233
|
+
if count.positive?
|
|
234
|
+
code_lines = (start...(start + count)).filter_map do |line|
|
|
235
|
+
line_range(bytes, line).merge(old_token_start: old_token_start) if code_line?(current_tokens, line)
|
|
236
|
+
end
|
|
237
|
+
if code_lines.empty?
|
|
238
|
+
changed << { anchor: offset_after_line(bytes, start - 1), old_token_start: old_token_start }
|
|
239
|
+
else
|
|
240
|
+
changed.concat(code_lines)
|
|
241
|
+
end
|
|
242
|
+
else
|
|
243
|
+
changed << { anchor: offset_after_line(bytes, start), old_token_start: old_token_start }
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
changed
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def code_line?(tokens, line)
|
|
250
|
+
tokens.any? do |token, _state|
|
|
251
|
+
line.between?(token.location.start_line, token.location.end_line) &&
|
|
252
|
+
!%i[COMMENT NEWLINE IGNORED_NEWLINE EOF].include?(token.type)
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def semantic_tokens(tokens, lines)
|
|
257
|
+
tokens.filter_map do |token, _state|
|
|
258
|
+
next unless token.location.start_line < lines.end && token.location.end_line >= lines.begin
|
|
259
|
+
|
|
260
|
+
if token.type == :COMMENT
|
|
261
|
+
next unless token.value.end_with?("\n")
|
|
262
|
+
|
|
263
|
+
next [:NEWLINE, "\n"]
|
|
264
|
+
end
|
|
265
|
+
next if %i[IGNORED_NEWLINE EOF].include?(token.type)
|
|
266
|
+
|
|
267
|
+
[token.type, token.value]
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def meaningful_tokens?(tokens)
|
|
272
|
+
tokens.any? { |type, _value| !%i[COMMENT NEWLINE IGNORED_NEWLINE EOF].include?(type) }
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# rubocop:disable-next Style/HashEachMethods -- Prism.lex returns token/state pairs in an Array.
|
|
276
|
+
def first_meaningful_token_start(tokens, lines)
|
|
277
|
+
tokens.each do |token, _state|
|
|
278
|
+
next unless token.location.start_line >= lines.begin && token.location.start_line < lines.end
|
|
279
|
+
next if %i[COMMENT NEWLINE IGNORED_NEWLINE EOF].include?(token.type)
|
|
280
|
+
|
|
281
|
+
return token.location.start_offset
|
|
282
|
+
end
|
|
283
|
+
nil
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def offset_after_line(bytes, line)
|
|
287
|
+
return 0 if line.zero?
|
|
288
|
+
|
|
289
|
+
bytes.lines.first(line).sum(&:bytesize)
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def line_range(bytes, line)
|
|
293
|
+
start_offset = offset_after_line(bytes, line - 1)
|
|
294
|
+
line_bytes = bytes.lines[line - 1].to_s
|
|
295
|
+
{ start_offset: start_offset, end_offset: start_offset + line_bytes.bytesize }
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# rubocop:disable-next Metrics/AbcSize, Metrics/MethodLength -- Handles byte ranges and deletion anchors consistently.
|
|
299
|
+
def range_touched?(location, changed)
|
|
300
|
+
if location.is_a?(Hash)
|
|
301
|
+
start_offset = location[:byte_start]
|
|
302
|
+
end_offset = start_offset + location[:byte_length]
|
|
303
|
+
else
|
|
304
|
+
start_offset = location.start_offset
|
|
305
|
+
end_offset = location.end_offset
|
|
306
|
+
end
|
|
307
|
+
changed.any? do |item|
|
|
308
|
+
if item.key?(:anchor)
|
|
309
|
+
item[:anchor] > start_offset && item[:anchor] < end_offset
|
|
310
|
+
else
|
|
311
|
+
item[:start_offset] < end_offset && item[:end_offset] > start_offset
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def body_touched?(node, changed)
|
|
317
|
+
body_locations(node).any? do |location|
|
|
318
|
+
location && changed.any? do |item|
|
|
319
|
+
if item.key?(:anchor)
|
|
320
|
+
item[:anchor] > location.start_offset && item[:anchor] < location.end_offset
|
|
321
|
+
else
|
|
322
|
+
item[:start_offset] < location.end_offset && item[:end_offset] > location.start_offset
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def branch_body_touched?(branch, changed)
|
|
329
|
+
location = branch.statements&.location
|
|
330
|
+
return false unless location
|
|
331
|
+
|
|
332
|
+
changed.any? do |item|
|
|
333
|
+
if item.key?(:anchor)
|
|
334
|
+
item[:anchor] > location.start_offset && item[:anchor] < location.end_offset
|
|
335
|
+
else
|
|
336
|
+
item[:start_offset] < location.end_offset && item[:end_offset] > location.start_offset
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- Metadata range shapes vary by Prism node family.
|
|
342
|
+
def body_locations(node)
|
|
343
|
+
if CONTROL_NODES.any? { |type| node.is_a?(type) }
|
|
344
|
+
[node.statements&.location, node.subsequent&.statements&.location]
|
|
345
|
+
elsif node.is_a?(Prism::CaseNode) || node.is_a?(Prism::CaseMatchNode)
|
|
346
|
+
node.conditions.map { |branch| branch.statements&.location } + [node.else_clause&.statements&.location]
|
|
347
|
+
else
|
|
348
|
+
[]
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def walk(node, &block)
|
|
353
|
+
return unless node
|
|
354
|
+
|
|
355
|
+
yield node
|
|
356
|
+
node.child_nodes.each { |child| walk(child, &block) if child }
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "pathname"
|
|
5
|
+
require_relative "git_changes"
|
|
6
|
+
require_relative "changed_decision_map"
|
|
7
|
+
|
|
8
|
+
module Branchproof
|
|
9
|
+
# Describes tracked files and current decision IDs affected by the direct
|
|
10
|
+
# commit-to-worktree comparison.
|
|
11
|
+
class ChangedScope
|
|
12
|
+
def initialize(root:, ref:)
|
|
13
|
+
raise ArgumentError, "root must be an absolute path" unless root.is_a?(String) && Pathname.new(root).absolute?
|
|
14
|
+
|
|
15
|
+
@root = File.realpath(root)
|
|
16
|
+
@ref = ref
|
|
17
|
+
rescue SystemCallError => e
|
|
18
|
+
raise ArgumentError, "project root is unavailable: #{e.message}"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity -- Coordinates Git capture, inventory checks, and ordered membership.
|
|
22
|
+
def call(inventory:)
|
|
23
|
+
validate_inventory!(inventory)
|
|
24
|
+
changes = GitChanges.new(root: @root, ref: @ref).call(include_patch_lines: true)
|
|
25
|
+
source_units = Array(value(inventory, :source_units))
|
|
26
|
+
decisions = Array(value(inventory, :decisions))
|
|
27
|
+
sources_by_path = source_units.to_h { |unit| [value(unit, :relative_path), unit] }
|
|
28
|
+
selected_ids = []
|
|
29
|
+
output_files = changes[:files].map do |file|
|
|
30
|
+
path = file[:path]
|
|
31
|
+
source = sources_by_path[path]
|
|
32
|
+
next public_file(file) unless source && file[:status] != "D"
|
|
33
|
+
|
|
34
|
+
bytes = current_bytes!(source, path)
|
|
35
|
+
original_decisions = decisions.select { |decision| decision[:source_id] == source[:source_id] }
|
|
36
|
+
if file[:status] == "A" || file[:status].start_with?("R")
|
|
37
|
+
ChangedDecisionMap.new.call(bytes: bytes, path: path, decisions: original_decisions, hunks: file[:hunks],
|
|
38
|
+
old_bytes: file[:old_bytes] || "")
|
|
39
|
+
selected_ids.concat(original_decisions.map { |decision| decision[:id] })
|
|
40
|
+
else
|
|
41
|
+
selected_ids.concat(ChangedDecisionMap.new.call(
|
|
42
|
+
bytes: bytes, path: path, decisions: original_decisions, hunks: file[:hunks],
|
|
43
|
+
old_bytes: file[:old_bytes] || ""
|
|
44
|
+
))
|
|
45
|
+
end
|
|
46
|
+
current_bytes!(source, path)
|
|
47
|
+
public_file(file)
|
|
48
|
+
end
|
|
49
|
+
after_changes = GitChanges.new(root: @root, ref: changes[:base_commit]).call(include_patch_lines: true)
|
|
50
|
+
unless after_changes[:files] == changes[:files]
|
|
51
|
+
raise ArgumentError,
|
|
52
|
+
"tracked Git changes moved while changed scope was being captured; retry with a fresh inventory"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
selected_ids = decisions.filter_map { |decision| decision[:id] if selected_ids.include?(decision[:id]) }
|
|
56
|
+
Records.build(**changes.except(:files),
|
|
57
|
+
status: selected_ids.empty? ? "empty" : "complete",
|
|
58
|
+
files: output_files, decision_ids: selected_ids)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def validate_inventory!(inventory)
|
|
64
|
+
raise ArgumentError, "inventory must be a source inventory record" unless inventory.is_a?(Hash)
|
|
65
|
+
raise ArgumentError, "inventory root does not match project root" unless File.realpath(value(inventory,
|
|
66
|
+
:root)) == @root
|
|
67
|
+
rescue SystemCallError, TypeError
|
|
68
|
+
raise ArgumentError, "inventory root does not match project root"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def current_bytes!(source, path)
|
|
72
|
+
bytes = value(source, :original_bytes)
|
|
73
|
+
digest = value(source, :digest)
|
|
74
|
+
current_path = File.expand_path(path, @root)
|
|
75
|
+
unless bytes.is_a?(String) && digest.is_a?(String) && Digest::SHA256.hexdigest(bytes) == digest &&
|
|
76
|
+
File.file?(current_path) && File.binread(current_path) == bytes
|
|
77
|
+
raise ArgumentError, "inventory source #{path.inspect} is stale; rebuild the current source inventory"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
bytes
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def public_file(file)
|
|
84
|
+
file.merge(hunks: Array(file[:hunks]).map do |hunk|
|
|
85
|
+
hunk.slice(:old_start, :old_count, :new_start, :new_count)
|
|
86
|
+
end).except(:old_bytes)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def value(hash, key)
|
|
90
|
+
hash[key] || hash[key.to_s]
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|