branchproof 0.6.0 → 0.8.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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +45 -0
  3. data/README.md +279 -27
  4. data/doc/Branchproof/Analyzer.md +2 -2
  5. data/doc/Branchproof/CLI.md +4 -0
  6. data/doc/Branchproof/ComparisonReport.md +4 -0
  7. data/doc/Branchproof/Constraints/Solver.md +21 -0
  8. data/doc/Branchproof/Constraints.md +98 -0
  9. data/doc/Branchproof/CoverageIndex.md +6 -0
  10. data/doc/Branchproof/DecisionSyntax.md +22 -0
  11. data/doc/Branchproof/DecisionTable.md +153 -0
  12. data/doc/Branchproof/FlowInstrumentation.md +7 -0
  13. data/doc/Branchproof/FocusedReport.md +5 -2
  14. data/doc/Branchproof/Instrumenter.md +1 -0
  15. data/doc/Branchproof/Loader.md +0 -4
  16. data/doc/Branchproof/MinitestAdapter.md +0 -3
  17. data/doc/Branchproof/Report.md +18 -0
  18. data/doc/Branchproof/Runtime.md +23 -0
  19. data/doc/Branchproof/RuntimeFlow.md +27 -0
  20. data/doc/Branchproof/SavedReport.md +21 -0
  21. data/doc/Branchproof/Source.md +1 -0
  22. data/doc/Branchproof.md +9 -2
  23. data/doc/CHANGELOG.md +45 -0
  24. data/doc/README.md +279 -27
  25. data/lib/branchproof/analyzer.rb +222 -54
  26. data/lib/branchproof/cli.rb +28 -19
  27. data/lib/branchproof/comparison.rb +207 -19
  28. data/lib/branchproof/comparison_report.rb +49 -1
  29. data/lib/branchproof/constraints.rb +363 -0
  30. data/lib/branchproof/coverage_index.rb +120 -3
  31. data/lib/branchproof/decision_syntax.rb +310 -0
  32. data/lib/branchproof/decision_table.rb +377 -0
  33. data/lib/branchproof/evidence.rb +101 -36
  34. data/lib/branchproof/flow_instrumentation.rb +107 -0
  35. data/lib/branchproof/focused_report.rb +161 -26
  36. data/lib/branchproof/instrumenter.rb +65 -39
  37. data/lib/branchproof/limits.rb +4 -1
  38. data/lib/branchproof/loader.rb +18 -6
  39. data/lib/branchproof/minimizer.rb +18 -13
  40. data/lib/branchproof/minitest_adapter.rb +11 -16
  41. data/lib/branchproof/records.rb +2 -0
  42. data/lib/branchproof/report.rb +470 -66
  43. data/lib/branchproof/runtime.rb +25 -30
  44. data/lib/branchproof/runtime_flow.rb +58 -0
  45. data/lib/branchproof/saved_report.rb +438 -13
  46. data/lib/branchproof/source.rb +239 -41
  47. data/lib/branchproof/version.rb +1 -1
  48. data/lib/branchproof/worker.rb +1 -4
  49. data/lib/branchproof.rb +2 -0
  50. data/llms.txt +9 -2
  51. data/sig/branchproof.rbs +54 -1
  52. metadata +12 -1
@@ -0,0 +1,310 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Branchproof
6
+ # Discovers control-flow expressions whose truth is not represented by an
7
+ # ordinary Prism IfNode. The records intentionally contain byte ranges and
8
+ # scalar metadata only; Prism nodes must not escape the source pass.
9
+ module DecisionSyntax
10
+ # Some of these node classes were added in later Prism 1.x releases, so
11
+ # look them up by name and skip any that this Prism version lacks.
12
+ def self.node_classes(*names)
13
+ names.filter_map { |name| Prism.const_get(name) if Prism.const_defined?(name) }.to_set.freeze
14
+ end
15
+ private_class_method :node_classes
16
+
17
+ OR_WRITE_NODE_CLASSES = node_classes(
18
+ :CallOrWriteNode, :ClassVariableOrWriteNode, :ConstantOrWriteNode,
19
+ :ConstantPathOrWriteNode, :GlobalVariableOrWriteNode, :IndexOrWriteNode,
20
+ :InstanceVariableOrWriteNode, :LocalVariableOrWriteNode
21
+ )
22
+
23
+ AND_WRITE_NODE_CLASSES = node_classes(
24
+ :CallAndWriteNode, :ClassVariableAndWriteNode, :ConstantAndWriteNode,
25
+ :ConstantPathAndWriteNode, :GlobalVariableAndWriteNode, :IndexAndWriteNode,
26
+ :InstanceVariableAndWriteNode, :LocalVariableAndWriteNode
27
+ )
28
+
29
+ # nodes: flow-decision nodes already collected by a caller's own AST walk
30
+ # (Source merges this discovery into one pass). Falls back to its own
31
+ # walk when nothing is passed in, so this method still works standalone.
32
+ def flow_decisions_for(program, bytes, source_id, file_reasons = [], encoding = "UTF-8", nodes: nil)
33
+ nodes ||= collect_flow_decision_nodes(program)
34
+ nodes.sort_by { |node| [node.location.start_offset, node.location.length] }.map do |node|
35
+ build_flow_decision(node, bytes, source_id, file_reasons, encoding)
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ def collect_flow_decision_nodes(program)
42
+ nodes = []
43
+ walk(program) { |node| nodes << node if flow_decision_node?(node) }
44
+ nodes
45
+ end
46
+
47
+ def flow_decision_node?(node)
48
+ return true if node.is_a?(Prism::CaseNode) && node.predicate
49
+ return true if node.is_a?(Prism::CaseMatchNode)
50
+ return true if node.is_a?(Prism::CallNode) && node.safe_navigation?
51
+ return true if assignment_node?(node)
52
+ return true if node.is_a?(Prism::RescueNode) || node.is_a?(Prism::RescueModifierNode)
53
+
54
+ false
55
+ end
56
+
57
+ def assignment_node?(node)
58
+ OR_WRITE_NODE_CLASSES.include?(node.class) || AND_WRITE_NODE_CLASSES.include?(node.class)
59
+ end
60
+
61
+ def build_flow_decision(node, bytes, source_id, file_reasons, encoding)
62
+ location = node.location
63
+ kind, context, alternatives, instrumentation, reasons = flow_details(node, bytes, encoding)
64
+ reasons = Array(file_reasons) + Array(reasons)
65
+ decision_id = Records.decision_id(source_id: source_id, context: context,
66
+ byte_start: location.start_offset, byte_length: location.length, tree: nil)
67
+ alternatives = alternatives.each_with_index.map do |alternative, index|
68
+ alternative.merge(index: index, id: Records.condition_id(decision_id, index))
69
+ end
70
+ limit = @limits[:conditions_per_decision] if defined?(@limits) && @limits.respond_to?(:[])
71
+ reasons << "alternative_limit_exceeded" if limit && alternatives.length > limit
72
+
73
+ Records.build(
74
+ id: decision_id,
75
+ source_id: source_id,
76
+ context: context,
77
+ kind: kind,
78
+ byte_start: location.start_offset,
79
+ byte_length: location.length,
80
+ line: location.start_line,
81
+ column: location.start_column,
82
+ expression: text_value(bytes.byteslice(location.start_offset, location.length), encoding),
83
+ tree: nil,
84
+ conditions: [],
85
+ alternatives: alternatives,
86
+ discovered_condition_count: 0,
87
+ support_status: reasons.empty? ? "SUPPORTED" : "UNSUPPORTED",
88
+ support_reasons: reasons.uniq,
89
+ opaque_ranges: [],
90
+ instrumentation: instrumentation
91
+ )
92
+ end
93
+
94
+ def flow_details(node, bytes, encoding)
95
+ case node
96
+ when Prism::CaseNode
97
+ case_details(node, bytes, encoding, kind: "multiway", context: "case")
98
+ when Prism::CaseMatchNode
99
+ case_match_details(node, bytes, encoding)
100
+ when Prism::CallNode
101
+ safe_navigation_details(node, bytes, encoding)
102
+ when Prism::RescueNode, Prism::RescueModifierNode
103
+ rescue_details(node, bytes, encoding)
104
+ else
105
+ assignment_details(node, bytes, encoding)
106
+ end
107
+ end
108
+
109
+ def case_details(node, bytes, encoding, kind:, context:)
110
+ conditions = node.conditions
111
+ candidates = []
112
+ branches = []
113
+ conditions.each_with_index do |branch, branch_index|
114
+ branch.conditions.each do |candidate|
115
+ candidates << {
116
+ expression: text_value(bytes.byteslice(candidate.location.start_offset, candidate.location.length),
117
+ encoding),
118
+ range: byte_range(candidate.location, splat_node?(candidate) ? "splat" : nil),
119
+ byte_start: candidate.location.start_offset,
120
+ byte_length: candidate.location.length
121
+ }
122
+ end
123
+ branches << {
124
+ index: branch_index,
125
+ insert_at: branch_insert_at(branch, conditions[branch_index + 1], node.else_clause, node.end_keyword_loc),
126
+ empty: statements_empty?(branch.statements)
127
+ }
128
+ end
129
+
130
+ else_clause = node.else_clause
131
+ else_index = candidates.length
132
+ candidates << if else_clause
133
+ { expression: "else", range: byte_range(else_clause.else_keyword_loc),
134
+ byte_start: else_clause.else_keyword_loc.start_offset,
135
+ byte_length: else_clause.else_keyword_loc.length }
136
+ else
137
+ { expression: "no_match", range: nil, byte_start: node.end_keyword_loc&.start_offset,
138
+ byte_length: 0 }
139
+ end
140
+ else_metadata = if else_clause
141
+ { insert_at: branch_insert_at(else_clause, nil, nil, node.end_keyword_loc), index: else_index,
142
+ empty: statements_empty?(else_clause.statements) }
143
+ end
144
+ reasons = unsupported_reasons(node, bytes)
145
+ reasons << "unsupported_case_splat" if candidates.any? do |candidate|
146
+ candidate[:range] && candidate[:range][:kind] == "splat"
147
+ end
148
+ instrumentation = {
149
+ type: "case",
150
+ range: byte_range(node.location),
151
+ predicate: node.predicate && byte_range(node.predicate.location),
152
+ candidates: candidates.reject { |candidate| candidate[:expression] == "else" || candidate[:range].nil? }
153
+ .each_with_index.map { |candidate, index| candidate.merge(index: index) },
154
+ branches: branches,
155
+ else: else_metadata,
156
+ end_start: node.end_keyword_loc&.start_offset
157
+ }
158
+ alternatives = candidates.map do |candidate|
159
+ candidate.slice(:expression, :byte_start, :byte_length)
160
+ end
161
+ [kind, context, alternatives, instrumentation, reasons]
162
+ end
163
+
164
+ def case_match_details(node, bytes, encoding)
165
+ conditions = node.conditions
166
+ candidates = []
167
+ branches = []
168
+ reasons = []
169
+ conditions.each_with_index do |branch, branch_index|
170
+ pattern = branch.pattern
171
+ guarded = pattern.is_a?(Prism::IfNode) || pattern.is_a?(Prism::UnlessNode)
172
+ guard = guarded ? pattern.predicate : nil
173
+ pattern_node = guarded ? pattern.statements&.body&.first : pattern
174
+ reasons << "unsupported_pattern_guard" if guard
175
+ candidate_node = pattern_node || pattern
176
+ candidates << {
177
+ expression: text_value(bytes.byteslice(candidate_node.location.start_offset, candidate_node.location.length),
178
+ encoding),
179
+ range: byte_range(candidate_node.location),
180
+ byte_start: candidate_node.location.start_offset,
181
+ byte_length: candidate_node.location.length,
182
+ guard: guard && byte_range(guard.location)
183
+ }
184
+ branches << {
185
+ index: branch_index,
186
+ insert_at: branch_insert_at(branch, conditions[branch_index + 1], node.else_clause, node.end_keyword_loc),
187
+ empty: statements_empty?(branch.statements)
188
+ }
189
+ end
190
+ else_clause = node.else_clause
191
+ else_metadata = if else_clause
192
+ { insert_at: branch_insert_at(else_clause, nil, nil, node.end_keyword_loc), index: nil,
193
+ empty: statements_empty?(else_clause.statements) }
194
+ end
195
+ instrumentation = {
196
+ type: "case_match",
197
+ range: byte_range(node.location),
198
+ predicate: node.predicate && byte_range(node.predicate.location),
199
+ candidates: candidates.reject { |candidate| candidate[:expression] == "else" || candidate[:range].nil? }
200
+ .each_with_index.map { |candidate, index| candidate.merge(index: index) },
201
+ branches: branches,
202
+ else: else_metadata,
203
+ end_start: node.end_keyword_loc&.start_offset
204
+ }
205
+ if node.else_clause
206
+ else_location = node.else_clause.else_keyword_loc
207
+ candidates << { expression: "else", range: byte_range(else_location),
208
+ byte_start: else_location.start_offset, byte_length: else_location.length }
209
+ else_index = candidates.length - 1
210
+ instrumentation[:else] = instrumentation[:else].merge(index: else_index)
211
+ end
212
+ alternatives = candidates.map { |candidate| candidate.slice(:expression, :byte_start, :byte_length) }
213
+ ["pattern", "case_in", alternatives, instrumentation, unsupported_reasons(node, bytes) + reasons]
214
+ end
215
+
216
+ def safe_navigation_details(node, bytes, _encoding)
217
+ receiver = node.receiver
218
+ instrumentation = {
219
+ type: "safe_navigation",
220
+ range: byte_range(node.location),
221
+ receiver: byte_range(receiver.location)
222
+ }
223
+ alternatives = [
224
+ { expression: "receiver nil", byte_start: receiver.location.start_offset,
225
+ byte_length: receiver.location.length },
226
+ { expression: "receiver non-nil", byte_start: receiver.location.start_offset,
227
+ byte_length: receiver.location.length }
228
+ ]
229
+ ["implicit", "safe_navigation", alternatives, instrumentation, unsupported_reasons(node, bytes)]
230
+ end
231
+
232
+ def assignment_details(node, bytes, _encoding)
233
+ rhs = node.value
234
+ operator = bytes.byteslice(node.operator_loc.start_offset, node.operator_loc.length)
235
+ assignment_context = operator == "||=" ? "or_assignment" : "and_assignment"
236
+ reasons = safe_navigation_assignment?(node) ? ["unsupported_assignment_target"] : []
237
+ instrumentation = {
238
+ type: "assignment",
239
+ range: byte_range(node.location),
240
+ rhs: byte_range(rhs.location),
241
+ operator: operator,
242
+ rhs_path: 1,
243
+ skipped_path: 0
244
+ }
245
+ alternatives = if operator == "||="
246
+ [{ expression: "LHS truthy; RHS skipped", byte_start: node.location.start_offset,
247
+ byte_length: node.location.length },
248
+ { expression: "LHS falsey; RHS executed", byte_start: node.location.start_offset,
249
+ byte_length: node.location.length }]
250
+ else
251
+ [{ expression: "LHS falsey; RHS skipped", byte_start: node.location.start_offset,
252
+ byte_length: node.location.length },
253
+ { expression: "LHS truthy; RHS executed", byte_start: node.location.start_offset,
254
+ byte_length: node.location.length }]
255
+ end
256
+ ["implicit", assignment_context, alternatives, instrumentation, unsupported_reasons(node, bytes) + reasons]
257
+ end
258
+
259
+ def rescue_details(node, bytes, encoding)
260
+ alternatives = if node.respond_to?(:exceptions)
261
+ Array(node.exceptions).map do |exception|
262
+ {
263
+ expression: text_value(
264
+ bytes.byteslice(exception.location.start_offset, exception.location.length), encoding
265
+ ),
266
+ byte_start: exception.location.start_offset,
267
+ byte_length: exception.location.length
268
+ }
269
+ end
270
+ else
271
+ []
272
+ end
273
+ instrumentation = { type: "rescue", range: byte_range(node.location) }
274
+ ["exception", "rescue", alternatives, instrumentation, ["unsupported_rescue_control_flow"]]
275
+ end
276
+
277
+ def branch_insert_at(branch, next_branch, else_clause, end_keyword_loc)
278
+ statements = branch.statements
279
+ return statements.location.start_offset unless statements_empty?(statements)
280
+
281
+ next_location = if next_branch
282
+ next_branch.is_a?(Prism::InNode) ? next_branch.in_loc : next_branch.keyword_loc
283
+ end
284
+ next_location&.start_offset || else_clause&.else_keyword_loc&.start_offset ||
285
+ end_keyword_loc&.start_offset || branch.location.end_offset
286
+ end
287
+
288
+ def statements_empty?(statements)
289
+ statements.nil? || Array(statements.body).empty?
290
+ end
291
+
292
+ def safe_navigation_assignment?(node)
293
+ return true if node.respond_to?(:safe_navigation?) && node.safe_navigation?
294
+
295
+ node.respond_to?(:call_operator_loc) && node.call_operator_loc&.slice == "&."
296
+ end
297
+
298
+ def splat_node?(node)
299
+ node.class.name.end_with?("SplatNode")
300
+ end
301
+
302
+ def byte_range(location, kind = nil)
303
+ return nil unless location
304
+
305
+ result = { byte_start: location.start_offset, byte_length: location.length }
306
+ result[:kind] = kind if kind
307
+ result
308
+ end
309
+ end
310
+ end
@@ -0,0 +1,377 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "records"
4
+ require_relative "constraints"
5
+ require_relative "limits"
6
+
7
+ module Branchproof
8
+ # Derives the executable logical rules of a supported Boolean decision and
9
+ # overlays the runtime evidence that the existing Branchproof run captured.
10
+ #
11
+ # Rules come from the Boolean tree itself rather than from an exhaustive
12
+ # Cartesian expansion, so Ruby's short-circuit semantics are preserved: a
13
+ # condition the interpreter would skip becomes an explicit +dont_care+ value
14
+ # instead of two separate rules.
15
+ #
16
+ # Nothing here executes application code, and no additional test run is
17
+ # required: overlay consumes the vectors already recorded for the decision.
18
+ module DecisionTable
19
+ SCHEMA_VERSION = 1
20
+ CONSTRAINT_ANALYSIS_VERSION = Constraints::VERSION
21
+ TRUE_VALUE = "true"
22
+ FALSE_VALUE = "false"
23
+ DONT_CARE = "dont_care"
24
+ VALUE_LABELS = { TRUE_VALUE => "T", FALSE_VALUE => "F", DONT_CARE => "-" }.freeze
25
+ CONDITION_VALUES = [TRUE_VALUE, FALSE_VALUE, DONT_CARE].freeze
26
+ REACHABILITY_STATUSES = %w[observed unknown statically_impossible].freeze
27
+ COVERAGE_STATUSES = %w[covered missing excluded].freeze
28
+ TABLE_STATUSES = %w[calculated not_calculated].freeze
29
+ COVERAGE_SUMMARY_STATUSES = %w[covered partial unexecuted unsupported not_calculated].freeze
30
+ NOT_CALCULATED_REASONS = %w[
31
+ unsupported_decision decision_table_unavailable
32
+ decision_table_condition_limit_exceeded decision_table_rule_limit_exceeded
33
+ ].freeze
34
+ DEFAULT_MAX_CONDITIONS = Limits::DEFAULTS[:max_conditions_for_decision_table]
35
+ DEFAULT_MAX_RULES = Limits::DEFAULTS[:decision_table_rules_per_decision]
36
+
37
+ module_function
38
+
39
+ # Builds the reduced, runtime-overlaid table for one Boolean decision.
40
+ def build(decision:, vectors: [], limits: {}, reachability: true)
41
+ raise ArgumentError, "reachability must be a Boolean" unless [true, false].include?(reachability)
42
+
43
+ decision_id = fetch(decision, :id).to_s
44
+ reason = unavailable_reason(decision, limits)
45
+ return not_calculated(decision_id, reason) if reason
46
+
47
+ max_rules = limit(limits, :decision_table_rules_per_decision, DEFAULT_MAX_RULES)
48
+ paths = enumerate(fetch(decision, :tree), false, max_rules + 1)
49
+ return not_calculated(decision_id, "decision_table_rule_limit_exceeded") if paths.nil? || paths.length > max_rules
50
+
51
+ conditions = Array(fetch(decision, :conditions))
52
+ prepared_constraints = conditions.map do |condition|
53
+ raw = fetch(condition, :constraint)
54
+ raw = Constraints.symbolize(raw)
55
+ fetch(condition, :constraint_safe) == true && Constraints.usable?(raw) ? raw : nil
56
+ end
57
+ rules = paths.each_with_index.map do |path, index|
58
+ rule(decision_id, conditions, path, index, reachability: reachability,
59
+ prepared_constraints: prepared_constraints)
60
+ end
61
+ overlay(decision_id: decision_id, rules: rules, vectors: Array(vectors), reachability: reachability,
62
+ indexed: unique_atom_indices?(fetch(decision, :tree)))
63
+ end
64
+
65
+ # Names why a decision carries no Boolean table, or nil when it carries one.
66
+ def unavailable_reason(decision, limits)
67
+ support = fetch(decision, :support_status).to_s
68
+ return "unsupported_decision" unless support.empty? || support.casecmp("supported").zero?
69
+
70
+ tree = fetch(decision, :tree)
71
+ return "decision_table_unavailable" if tree.nil?
72
+
73
+ conditions = Array(fetch(decision, :conditions)).length
74
+ return "decision_table_condition_limit_exceeded" if conditions >
75
+ limit(limits, :max_conditions_for_decision_table,
76
+ DEFAULT_MAX_CONDITIONS)
77
+ return "decision_table_unavailable" unless representable?(tree, conditions)
78
+
79
+ nil
80
+ end
81
+
82
+ # Only AND, OR, NOT, and atoms within the decision's own condition range are
83
+ # representable; anything else leaves the table uncalculated rather than
84
+ # producing a table that does not describe the decision.
85
+ def representable?(node, condition_count)
86
+ case fetch(node, :type).to_s
87
+ when "atom" then fetch(node, :index).is_a?(Integer) && (0...condition_count).cover?(fetch(node, :index))
88
+ when "not" then representable?(fetch(node, :child), condition_count)
89
+ when "and", "or"
90
+ representable?(fetch(node, :left), condition_count) &&
91
+ representable?(fetch(node, :right), condition_count)
92
+ else false
93
+ end
94
+ end
95
+
96
+ def unique_atom_indices?(node, seen = {})
97
+ case fetch(node, :type).to_s
98
+ when "atom"
99
+ index = fetch(node, :index)
100
+ return false if seen.key?(index)
101
+
102
+ seen[index] = true
103
+ true
104
+ when "not"
105
+ unique_atom_indices?(fetch(node, :child), seen)
106
+ when "and", "or"
107
+ unique_atom_indices?(fetch(node, :left), seen) && unique_atom_indices?(fetch(node, :right), seen)
108
+ else false
109
+ end
110
+ end
111
+
112
+ # Enumerates every short-circuit evaluation path of the Boolean tree.
113
+ #
114
+ # +prefer+ orders the paths deterministically: a conjunction lists its
115
+ # short-circuiting false path first, a disjunction its true path first, and
116
+ # a negation inverts the preference it inherits. Returns nil once the path
117
+ # count would exceed +budget+.
118
+ def enumerate(node, prefer, budget)
119
+ type = fetch(node, :type).to_s
120
+ case type
121
+ when "atom"
122
+ index = fetch(node, :index).to_i
123
+ (prefer ? [true, false] : [false, true]).map { |value| { assignments: { index => value }, value: value } }
124
+ when "not"
125
+ paths = enumerate(fetch(node, :child), !prefer, budget)
126
+ paths&.map { |path| { assignments: path[:assignments], value: !path[:value] } }
127
+ when "and", "or"
128
+ combine(node, type, budget)
129
+ end
130
+ end
131
+
132
+ def combine(node, type, budget)
133
+ short_circuit = type != "and"
134
+ left = enumerate(fetch(node, :left), short_circuit, budget)
135
+ return nil unless left
136
+
137
+ right = nil
138
+ paths = []
139
+ left.each do |path|
140
+ if path[:value] == short_circuit
141
+ paths << path
142
+ next
143
+ end
144
+ right ||= enumerate(fetch(node, :right), short_circuit, budget)
145
+ return nil unless right
146
+
147
+ right.each do |tail|
148
+ paths << { assignments: path[:assignments].merge(tail[:assignments]), value: tail[:value] }
149
+ end
150
+ return nil if paths.length > budget
151
+ end
152
+ paths.length > budget ? nil : paths
153
+ end
154
+
155
+ # rubocop:disable-next Metrics/ParameterLists
156
+ def rule(decision_id, conditions, path, index, reachability: true, prepared_constraints: nil)
157
+ values = Array.new(conditions.length, DONT_CARE)
158
+ path[:assignments].each do |condition_index, value|
159
+ values[condition_index] = value ? TRUE_VALUE : FALSE_VALUE if condition_index < values.length
160
+ end
161
+ outcome = path[:value] ? true : false
162
+ reachability_state, reason = if reachability
163
+ static_reachability(conditions, values, prepared_constraints: prepared_constraints)
164
+ else
165
+ ["unknown", nil]
166
+ end
167
+ { id: rule_id(decision_id, values, outcome), label: "R#{index + 1}", index: index,
168
+ conditions: values, outcome: outcome, reachability: reachability_state, reachability_reason: reason }
169
+ end
170
+
171
+ # Rule identity depends only on the decision, the normalized condition
172
+ # vector, the expected outcome, and the table schema version. It never
173
+ # depends on a test name, a runtime observation, or the Minitest seed.
174
+ def rule_id(decision_id, values, outcome)
175
+ Records.id(schema_version: SCHEMA_VERSION, decision_id: decision_id.to_s,
176
+ conditions: values, outcome: outcome)
177
+ end
178
+
179
+ # Conservative static reachability: prove impossibility, or answer unknown.
180
+ def static_reachability(conditions, values, prepared_constraints: nil)
181
+ solver = Constraints::Solver.new
182
+ values.each_with_index do |value, index|
183
+ next if value == DONT_CARE
184
+
185
+ condition = conditions[index] || {}
186
+ truth = value == TRUE_VALUE
187
+ literal = fetch(condition, :literal_truth)
188
+ return %w[statically_impossible boolean_literal_conflict] if !literal.nil? && literal != truth
189
+
190
+ next unless fetch(condition, :constraint_safe) == true
191
+
192
+ prepared = prepared_constraints && prepared_constraints[index]
193
+ reason = if prepared
194
+ solver.add_prepared(prepared, truth)
195
+ else
196
+ solver.add(fetch(condition, :constraint), truth)
197
+ end
198
+ return ["statically_impossible", reason] if reason
199
+ end
200
+ ["unknown", nil]
201
+ end
202
+
203
+ def overlay(decision_id:, rules:, vectors:, reachability: true, indexed: false)
204
+ diagnostics = []
205
+ matches = indexed ? classify_vectors(rules, vectors) : generic_matches(rules, vectors)
206
+ overlaid = rules.each_with_index.map do |item, index|
207
+ overlay_rule_matches(decision_id, item, matches[index], diagnostics)
208
+ end
209
+ summary(decision_id: decision_id, rules: overlaid, diagnostics: diagnostics,
210
+ reachability_analyzed: reachability)
211
+ end
212
+
213
+ # Generated traces have nil in every skipped condition position. Their
214
+ # normalized vector is therefore an exact table key and can be classified
215
+ # without scanning every rule. Malformed or hand-built observations retain
216
+ # the historical matcher as a bounded compatibility fallback.
217
+ def classify_vectors(rules, vectors)
218
+ condition_count = rules.first ? Array(rules.first[:conditions]).length : 0
219
+ by_signature = {}
220
+ rules.each_with_index do |item, position|
221
+ (by_signature[signature(item[:conditions], item[:outcome])] ||= []) << position
222
+ end
223
+ matches = Array.new(rules.length) { [] }
224
+ vectors.each do |vector|
225
+ positions = fast_match_positions(vector, by_signature, condition_count)
226
+ if positions&.length == 1
227
+ matches[positions.first] << vector
228
+ else
229
+ rules.each_with_index do |item, position|
230
+ matches[position] << vector if matches?(item, vector)
231
+ end
232
+ end
233
+ end
234
+ matches
235
+ end
236
+
237
+ def generic_matches(rules, vectors)
238
+ matches = Array.new(rules.length) { [] }
239
+ rules.each_with_index do |rule, position|
240
+ vectors.each { |vector| matches[position] << vector if matches?(rule, vector) }
241
+ end
242
+ matches
243
+ end
244
+
245
+ def signature(values, outcome)
246
+ [outcome ? true : false, Array(values).map { |value| value == DONT_CARE ? nil : value == TRUE_VALUE }]
247
+ end
248
+
249
+ def fast_match_positions(vector, by_signature, condition_count)
250
+ values = Array(fetch(vector, :values))
251
+ valid_values = values.all? { |value| value.nil? || value == true || value == false }
252
+ return nil unless values.length >= condition_count && valid_values
253
+
254
+ normalized = values.first(condition_count).map do |value|
255
+ if value.nil?
256
+ DONT_CARE
257
+ elsif value
258
+ TRUE_VALUE
259
+ else
260
+ FALSE_VALUE
261
+ end
262
+ end
263
+ by_signature[signature(normalized, fetch(vector, :outcome))]
264
+ end
265
+
266
+ # Public compatibility wrapper: callers historically passed all vectors,
267
+ # so retain matcher filtering for direct calls.
268
+ def overlay_rule(decision_id, rule, vectors, diagnostics)
269
+ matched = Array(vectors).select { |vector| matches?(rule, vector) }
270
+ overlay_rule_matches(decision_id, rule, matched, diagnostics)
271
+ end
272
+
273
+ def overlay_rule_matches(decision_id, rule, matched, diagnostics)
274
+ observed = !matched.empty?
275
+ impossible = rule[:reachability] == "statically_impossible"
276
+ withdrawn = observed && impossible
277
+ if withdrawn
278
+ diagnostics << Records.diagnostic(
279
+ code: "constraint_model_conflict", severity: "warning",
280
+ message: "runtime evidence matched a statically impossible decision-table rule; " \
281
+ "the impossibility claim is withdrawn",
282
+ decision_id: decision_id,
283
+ details: { rule_id: rule[:id], rule_label: rule[:label],
284
+ reachability_reason: rule[:reachability_reason].to_s }
285
+ )
286
+ end
287
+ rule.merge(
288
+ coverage: if observed
289
+ "covered"
290
+ else
291
+ (impossible ? "excluded" : "missing")
292
+ end,
293
+ reachability: observed ? "observed" : rule[:reachability],
294
+ reachability_reason: observed ? nil : rule[:reachability_reason],
295
+ impossible_withdrawn: withdrawn,
296
+ withdrawn_reason: withdrawn ? rule[:reachability_reason] : nil,
297
+ tests: matched.flat_map { |vector| Array(fetch(vector, :test_ids)).map(&:to_s) }.uniq.sort,
298
+ vector_ids: matched.map { |vector| fetch(vector, :id).to_s }.uniq.sort,
299
+ unattributed_count: matched.sum { |vector| fetch(vector, :unattributed_count).to_i }
300
+ )
301
+ end
302
+
303
+ # A runtime observation matches a rule when every required condition value
304
+ # matches and the decision outcome matches. Conditions Ruby skipped may only
305
+ # line up with don't-care positions.
306
+ def matches?(rule, vector)
307
+ return false unless (fetch(vector, :outcome) ? true : false) == rule[:outcome]
308
+
309
+ values = Array(fetch(vector, :values))
310
+ rule[:conditions].each_with_index.all? do |required, index|
311
+ next true if required == DONT_CARE
312
+
313
+ values[index] == (required == TRUE_VALUE)
314
+ end
315
+ end
316
+
317
+ def summary(decision_id:, rules:, diagnostics:, reachability_analyzed: true)
318
+ generated = rules.length
319
+ impossible = rules.count { |rule| rule[:coverage] == "excluded" }
320
+ required = generated - impossible
321
+ covered = rules.count { |rule| rule[:coverage] == "covered" }
322
+ { status: "calculated", reason: nil, decision_id: decision_id,
323
+ schema_version: SCHEMA_VERSION, constraint_analysis_version: CONSTRAINT_ANALYSIS_VERSION,
324
+ rules: rules, generated_rules: generated, impossible_rules: impossible,
325
+ required_rules: required, covered_rules: covered, missing_rules: required - covered,
326
+ coverage_status: coverage_status(covered, required, generated),
327
+ percentage: percentage(covered, required), reachability_analyzed: reachability_analyzed,
328
+ diagnostics: diagnostics }
329
+ end
330
+
331
+ def coverage_status(covered, required, generated)
332
+ return "unexecuted" if generated.zero?
333
+ return "covered" if required.zero? || covered == required
334
+ return "unexecuted" if covered.zero?
335
+
336
+ "partial"
337
+ end
338
+
339
+ def not_calculated(decision_id, reason)
340
+ { status: "not_calculated", reason: reason, decision_id: decision_id,
341
+ schema_version: SCHEMA_VERSION, constraint_analysis_version: CONSTRAINT_ANALYSIS_VERSION,
342
+ rules: [], generated_rules: 0, impossible_rules: 0, required_rules: 0, covered_rules: 0,
343
+ missing_rules: 0,
344
+ coverage_status: reason == "unsupported_decision" ? "unsupported" : "not_calculated",
345
+ percentage: nil, reachability_analyzed: false, diagnostics: [] }
346
+ end
347
+
348
+ # The per-decision row the coverage ladder renders.
349
+ def coverage_entry(table)
350
+ { status: table[:coverage_status], table_status: table[:status], reason: table[:reason],
351
+ covered_rules: table[:covered_rules], required_rules: table[:required_rules],
352
+ generated_rules: table[:generated_rules], impossible_rules: table[:impossible_rules],
353
+ missing_rules: table[:missing_rules], percentage: table[:percentage],
354
+ reachability_analyzed: table[:reachability_analyzed] }
355
+ end
356
+
357
+ def unsupported_coverage_entry
358
+ coverage_entry(not_calculated(nil, "unsupported_decision"))
359
+ end
360
+
361
+ def percentage(covered, required)
362
+ required.zero? ? nil : (covered.to_f / required * 100).round(2)
363
+ end
364
+
365
+ def limit(limits, key, fallback)
366
+ value = fetch(limits, key)
367
+ value.is_a?(Integer) && value.positive? ? value : fallback
368
+ end
369
+
370
+ def fetch(record, key)
371
+ return nil unless record.respond_to?(:key?)
372
+ return record[key] if record.key?(key)
373
+
374
+ record[key.to_s]
375
+ end
376
+ end
377
+ end