branchproof 0.11.1 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +82 -0
  3. data/README.md +440 -29
  4. data/doc/Branchproof/CLI.md +1 -1
  5. data/doc/Branchproof/ChangedCoverage.md +19 -0
  6. data/doc/Branchproof/ChangedCoveragePolicy.md +24 -0
  7. data/doc/Branchproof/ChangedDecisionMap.md +20 -0
  8. data/doc/Branchproof/ChangedScope.md +18 -0
  9. data/doc/Branchproof/Collation.md +23 -0
  10. data/doc/Branchproof/CollationManifest.md +29 -0
  11. data/doc/Branchproof/CoverageSummary.md +20 -0
  12. data/doc/Branchproof/Doctor/TerminalRenderer.md +15 -0
  13. data/doc/Branchproof/Doctor.md +34 -0
  14. data/doc/Branchproof/FallbackInstrumentation.md +8 -0
  15. data/doc/Branchproof/FallbackRuntime.md +12 -0
  16. data/doc/Branchproof/FallbackSyntax.md +13 -0
  17. data/doc/Branchproof/GitChanges.md +19 -0
  18. data/doc/Branchproof/GithubReport.md +25 -0
  19. data/doc/Branchproof/HtmlReport.md +22 -0
  20. data/doc/Branchproof/Instrumenter.md +1 -1
  21. data/doc/Branchproof/MinitestAdapter.md +4 -0
  22. data/doc/Branchproof/RakeTask.md +121 -0
  23. data/doc/Branchproof/Report.md +41 -3
  24. data/doc/Branchproof/ReportSelection.md +10 -1
  25. data/doc/Branchproof/Runtime.md +4 -1
  26. data/doc/Branchproof/Source.md +1 -1
  27. data/doc/Branchproof/SummaryRanking/Decision.md +86 -0
  28. data/doc/Branchproof/SummaryRanking/SourceFile.md +43 -0
  29. data/doc/Branchproof/SummaryRanking.md +36 -0
  30. data/doc/Branchproof/SummaryReport.md +42 -0
  31. data/doc/Branchproof/Worker.md +1 -1
  32. data/doc/Branchproof.md +21 -1
  33. data/doc/CHANGELOG.md +82 -0
  34. data/doc/README.md +440 -29
  35. data/lib/branchproof/analyzer.rb +2 -52
  36. data/lib/branchproof/changed_coverage.rb +177 -0
  37. data/lib/branchproof/changed_decision_map.rb +359 -0
  38. data/lib/branchproof/changed_scope.rb +93 -0
  39. data/lib/branchproof/cli.rb +242 -21
  40. data/lib/branchproof/collation.rb +525 -0
  41. data/lib/branchproof/collation_manifest.rb +129 -0
  42. data/lib/branchproof/comparison.rb +11 -3
  43. data/lib/branchproof/comparison_report.rb +2 -0
  44. data/lib/branchproof/configuration.rb +12 -2
  45. data/lib/branchproof/coverage_policy.rb +122 -0
  46. data/lib/branchproof/coverage_summary.rb +94 -0
  47. data/lib/branchproof/doctor.rb +241 -0
  48. data/lib/branchproof/evidence.rb +5 -1
  49. data/lib/branchproof/exception_instrumentation.rb +4 -3
  50. data/lib/branchproof/exception_syntax.rb +15 -1
  51. data/lib/branchproof/fallback_instrumentation.rb +25 -0
  52. data/lib/branchproof/fallback_runtime.rb +14 -0
  53. data/lib/branchproof/fallback_syntax.rb +90 -0
  54. data/lib/branchproof/focused_report.rb +81 -22
  55. data/lib/branchproof/git_changes.rb +188 -0
  56. data/lib/branchproof/github_report.rb +292 -0
  57. data/lib/branchproof/html_report.rb +412 -0
  58. data/lib/branchproof/instrumenter.rb +2 -0
  59. data/lib/branchproof/minimizer.rb +4 -2
  60. data/lib/branchproof/minitest_adapter.rb +45 -5
  61. data/lib/branchproof/rails_support.rb +2 -2
  62. data/lib/branchproof/rake_task.rb +186 -0
  63. data/lib/branchproof/report.rb +383 -71
  64. data/lib/branchproof/report_selection.rb +26 -5
  65. data/lib/branchproof/runtime.rb +2 -0
  66. data/lib/branchproof/saved_report.rb +323 -5
  67. data/lib/branchproof/source.rb +15 -3
  68. data/lib/branchproof/summary_ranking.rb +165 -0
  69. data/lib/branchproof/summary_report.rb +241 -0
  70. data/lib/branchproof/version.rb +1 -1
  71. data/lib/branchproof/worker.rb +14 -9
  72. data/lib/branchproof.rb +12 -0
  73. data/llms.txt +21 -1
  74. data/sig/branchproof.rbs +206 -5
  75. metadata +39 -23
@@ -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