branchproof 0.7.0 → 0.9.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 +53 -0
  3. data/README.md +255 -21
  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 +3 -0
  10. data/doc/Branchproof/DecisionSyntax.md +6 -4
  11. data/doc/Branchproof/DecisionTable.md +153 -0
  12. data/doc/Branchproof/DefaultInstrumentation.md +13 -0
  13. data/doc/Branchproof/DefaultRuntime.md +11 -0
  14. data/doc/Branchproof/DefaultSyntax.md +7 -0
  15. data/doc/Branchproof/ExceptionInstrumentation.md +9 -0
  16. data/doc/Branchproof/ExceptionRuntime.md +27 -0
  17. data/doc/Branchproof/ExceptionSyntax.md +9 -0
  18. data/doc/Branchproof/ExtendedAlternativeRuntime.md +19 -0
  19. data/doc/Branchproof/FocusedReport.md +5 -2
  20. data/doc/Branchproof/Instrumenter.md +1 -1
  21. data/doc/Branchproof/IterationInstrumentation.md +7 -0
  22. data/doc/Branchproof/IterationRuntime.md +21 -0
  23. data/doc/Branchproof/IterationSyntax.md +11 -0
  24. data/doc/Branchproof/Loader.md +0 -4
  25. data/doc/Branchproof/MinitestAdapter.md +0 -3
  26. data/doc/Branchproof/Report.md +18 -0
  27. data/doc/Branchproof/Runtime.md +60 -1
  28. data/doc/Branchproof/SavedReport.md +15 -0
  29. data/doc/Branchproof/Source.md +7 -1
  30. data/doc/Branchproof/ValueInstrumentation.md +7 -0
  31. data/doc/Branchproof/ValueRuntime.md +26 -0
  32. data/doc/Branchproof/ValueSyntax.md +26 -0
  33. data/doc/Branchproof.md +20 -3
  34. data/doc/CHANGELOG.md +53 -0
  35. data/doc/README.md +255 -21
  36. data/lib/branchproof/analyzer.rb +80 -44
  37. data/lib/branchproof/cli.rb +28 -19
  38. data/lib/branchproof/comparison.rb +207 -19
  39. data/lib/branchproof/comparison_report.rb +49 -1
  40. data/lib/branchproof/constraints.rb +363 -0
  41. data/lib/branchproof/coverage_index.rb +30 -1
  42. data/lib/branchproof/decision_syntax.rb +50 -24
  43. data/lib/branchproof/decision_table.rb +377 -0
  44. data/lib/branchproof/default_instrumentation.rb +140 -0
  45. data/lib/branchproof/default_runtime.rb +16 -0
  46. data/lib/branchproof/default_syntax.rb +94 -0
  47. data/lib/branchproof/evidence.rb +133 -31
  48. data/lib/branchproof/exception_instrumentation.rb +96 -0
  49. data/lib/branchproof/exception_runtime.rb +41 -0
  50. data/lib/branchproof/exception_syntax.rb +157 -0
  51. data/lib/branchproof/extended_alternative_runtime.rb +23 -0
  52. data/lib/branchproof/flow_instrumentation.rb +52 -23
  53. data/lib/branchproof/focused_report.rb +77 -9
  54. data/lib/branchproof/instrumenter.rb +93 -37
  55. data/lib/branchproof/iteration_instrumentation.rb +66 -0
  56. data/lib/branchproof/iteration_runtime.rb +79 -0
  57. data/lib/branchproof/iteration_syntax.rb +75 -0
  58. data/lib/branchproof/limits.rb +4 -1
  59. data/lib/branchproof/loader.rb +24 -7
  60. data/lib/branchproof/minimizer.rb +18 -13
  61. data/lib/branchproof/minitest_adapter.rb +11 -16
  62. data/lib/branchproof/records.rb +2 -0
  63. data/lib/branchproof/report.rb +301 -68
  64. data/lib/branchproof/runtime.rb +32 -30
  65. data/lib/branchproof/saved_report.rb +200 -14
  66. data/lib/branchproof/source.rb +140 -71
  67. data/lib/branchproof/value_instrumentation.rb +35 -0
  68. data/lib/branchproof/value_runtime.rb +73 -0
  69. data/lib/branchproof/value_syntax.rb +116 -0
  70. data/lib/branchproof/version.rb +1 -1
  71. data/lib/branchproof/worker.rb +1 -4
  72. data/lib/branchproof.rb +2 -0
  73. data/llms.txt +20 -3
  74. data/sig/branchproof.rbs +64 -1
  75. metadata +32 -1
@@ -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
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Keep source-boundary and parameter-binding rules together for auditing.
4
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/ModuleLength, Metrics/PerceivedComplexity
5
+
6
+ module Branchproof
7
+ # Defaults stay inline in their original lexical scope. The owner decision
8
+ # makes body entry edits part of the same original AST edit tree as the
9
+ # parameter expression edits.
10
+ module DefaultInstrumentation
11
+ def rewrite(unit:)
12
+ decisions = Array(unit[:decisions])
13
+ defaults = decisions.select do |decision|
14
+ decision.dig(:instrumentation, :type) == "default" && supported?(decision)
15
+ end
16
+ return super unless defaults.any?
17
+
18
+ prepared = decisions.map do |decision|
19
+ next decision unless defaults.include?(decision)
20
+
21
+ instrumentation = decision[:instrumentation].merge(flag: default_flag(decision, unit[:original_bytes]))
22
+ decision.merge(instrumentation: instrumentation)
23
+ end
24
+ supported_ids = defaults.map { |decision| decision[:id] }
25
+ prepared_defaults = prepared.select do |decision|
26
+ decision.dig(:instrumentation, :type) == "default" && supported_ids.include?(decision[:id])
27
+ end
28
+ super(unit: unit.merge(decisions: owner_decisions(prepared, prepared_defaults)))
29
+ end
30
+
31
+ private
32
+
33
+ # An enclosing flow renderer can split a callable range at its own
34
+ # insertion point (for example an iteration callback inside a block). In
35
+ # that case the owner decision cannot be rendered as one child. Preserve
36
+ # the original offset by placing its supplied markers at the chunk that
37
+ # begins at the body entry.
38
+ def render_children(bytes, start, length, nested, encloses)
39
+ output = super
40
+ finish = start + length
41
+ crossing = nested.select do |child|
42
+ next false unless child.dig(:instrumentation, :type) == "default_owner"
43
+
44
+ owner = child.dig(:instrumentation, :owner)
45
+ owner && owner[:body_start] == start &&
46
+ (owner[:byte_start] < start || owner[:byte_start] + owner[:byte_length] > finish)
47
+ end
48
+ return output if crossing.empty?
49
+
50
+ marker = crossing.flat_map { |owner| owner_markers(owner, encloses[owner] || [], bytes) }.join
51
+ marker.empty? ? output : "#{marker}#{output}"
52
+ end
53
+
54
+ def encloses_decision?(outer, inner)
55
+ same_range = outer[:byte_start] == inner[:byte_start] && outer[:byte_length] == inner[:byte_length]
56
+ if same_range && inner.dig(:instrumentation, :type) == "default_owner"
57
+ return true unless outer.dig(:instrumentation, :type) == "default_owner"
58
+ elsif same_range && outer.dig(:instrumentation, :type) == "default_owner"
59
+ return false
60
+ end
61
+ super
62
+ end
63
+
64
+ def render_flow(bytes, decision, nested, encloses)
65
+ if decision.dig(:instrumentation, :type) == "default_owner"
66
+ return render_default_owner(bytes, decision, nested, encloses)
67
+ end
68
+ return super unless decision.dig(:instrumentation, :type) == "default"
69
+
70
+ value = decision.dig(:instrumentation, :value_range)
71
+ flag = decision.dig(:instrumentation, :flag) || default_flag(decision, bytes)
72
+ replacement = flow_replacement(bytes, value, nested, encloses) do |expression|
73
+ "(begin; #{flag} = true; #{self.class::RUNTIME}.default_binding(#{decision[:id].inspect}, 1); " \
74
+ "#{expression}; end)"
75
+ end
76
+ flow_fragments(bytes, decision, nested, [replacement], encloses)
77
+ end
78
+
79
+ def owner_decisions(decisions, defaults)
80
+ owners = defaults.group_by { |decision| decision.dig(:instrumentation, :owner) }
81
+ additions = owners.filter_map do |owner, grouped|
82
+ next unless owner
83
+
84
+ first = grouped.min_by { |decision| decision[:byte_start] }
85
+ next unless first
86
+
87
+ first.merge(
88
+ byte_start: owner[:byte_start], byte_length: owner[:byte_length],
89
+ expression: nil,
90
+ instrumentation: first[:instrumentation].merge(type: "default_owner")
91
+ )
92
+ end
93
+ (decisions + additions).sort_by { |decision| [decision[:byte_start], decision[:byte_length]] }
94
+ end
95
+
96
+ def render_default_owner(bytes, decision, nested, encloses)
97
+ owner = decision.dig(:instrumentation, :owner)
98
+ return super unless owner
99
+
100
+ bindings = nested.filter_map do |child|
101
+ next unless child.dig(:instrumentation, :type) == "default"
102
+ next unless child.dig(:instrumentation, :owner) == owner
103
+
104
+ [child.dig(:instrumentation, :flag) || default_flag(child, bytes), child[:id]]
105
+ end
106
+ marker = bindings.map do |flag, id|
107
+ "#{self.class::RUNTIME}.default_binding(#{id.inspect}, 0) unless #{flag}; "
108
+ end.join
109
+ return flow_fragments(bytes, decision, nested, [], encloses) if marker.empty?
110
+
111
+ start = owner[:body_start] || owner[:closing_start]
112
+ finish = (owner[:body_start] + owner[:body_length] if owner[:equal] && owner[:body_start] && owner[:body_length])
113
+ replacements = if finish
114
+ [{ start: start, length: 0, text: "(begin; #{marker}" },
115
+ { start: finish, length: 0, text: "; end)" }]
116
+ else
117
+ [{ start: start, length: 0, text: marker }]
118
+ end
119
+ flow_fragments(bytes, decision, nested, replacements, encloses)
120
+ end
121
+
122
+ def owner_markers(owner, nested, bytes)
123
+ nested.filter_map do |child|
124
+ next unless child.dig(:instrumentation, :type) == "default"
125
+ next unless child.dig(:instrumentation, :owner) == owner.dig(:instrumentation, :owner)
126
+
127
+ flag = child.dig(:instrumentation, :flag) || default_flag(child, bytes)
128
+ "#{self.class::RUNTIME}.default_binding(#{child[:id].inspect}, 0) unless #{flag}; "
129
+ end
130
+ end
131
+
132
+ def default_flag(decision, bytes)
133
+ name = "__branchproof_default_#{decision[:id]}"
134
+ name += "_" while bytes.include?(name)
135
+ name
136
+ end
137
+ end
138
+ end
139
+
140
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/ModuleLength, Metrics/PerceivedComplexity
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Branchproof
4
+ # Binding events are immediate; no pending state survives a failed default.
5
+ module DefaultRuntime
6
+ def default_binding(decision_id, index)
7
+ enter(decision_id)
8
+ begin
9
+ flow_path(decision_id, index)
10
+ ensure
11
+ leave(decision_id)
12
+ end
13
+ nil
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Branchproof
4
+ # Each optional parameter has an independent supplied/defaulted obligation.
5
+ module DefaultSyntax
6
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
7
+
8
+ private
9
+
10
+ def flow_decision_node?(node)
11
+ @default_owner_by_node = {}.compare_by_identity if node.is_a?(Prism::ProgramNode)
12
+ register_default_owner(node) if default_owner_node?(node)
13
+ default_parameter_node?(node) || super
14
+ end
15
+
16
+ def flow_details(node, bytes, encoding)
17
+ return super unless default_parameter_node?(node)
18
+
19
+ value = node.value
20
+ alternatives = [
21
+ { expression: "#{node.name} supplied", byte_start: node.location.start_offset,
22
+ byte_length: node.location.length },
23
+ { expression: "#{node.name} default evaluated", byte_start: value.location.start_offset,
24
+ byte_length: value.location.length }
25
+ ]
26
+ owner = @default_owner_by_node && @default_owner_by_node[node]
27
+ metadata = { type: "default", value_range: byte_range(value.location) }
28
+ metadata[:owner] = owner if owner
29
+ ["implicit", "default_argument", alternatives, metadata, unsupported_reasons(node, bytes)]
30
+ end
31
+
32
+ def register_default_owner(owner)
33
+ @default_owner_by_node ||= {}.compare_by_identity
34
+ parameters = owner.parameters if owner.respond_to?(:parameters)
35
+ parameters = parameters.parameters if parameters.is_a?(Prism::BlockParametersNode)
36
+ return unless parameters.is_a?(Prism::ParametersNode)
37
+
38
+ details = default_owner_details(owner)
39
+ (Array(parameters.optionals) + Array(parameters.keywords)).each do |parameter|
40
+ next unless default_parameter_node?(parameter)
41
+
42
+ @default_owner_by_node[parameter] = details
43
+ end
44
+ end
45
+
46
+ def default_parameter_node?(node)
47
+ node.is_a?(Prism::OptionalParameterNode) || node.is_a?(Prism::OptionalKeywordParameterNode)
48
+ end
49
+
50
+ def default_owner_node?(node)
51
+ node.is_a?(Prism::DefNode) || node.is_a?(Prism::LambdaNode) || node.is_a?(Prism::BlockNode)
52
+ end
53
+
54
+ def default_owner_details(owner)
55
+ return nil unless owner
56
+
57
+ # Only endless `def name(...) = expr` has a body that can be wrapped as
58
+ # one expression. Lambda/block `closing_loc` values are delimiters, not
59
+ # endless-definition markers, and must stay on the statement path.
60
+ equal_loc = owner.is_a?(Prism::DefNode) && owner.equal_loc
61
+ closing = if owner.is_a?(Prism::DefNode)
62
+ owner.end_keyword_loc
63
+ else
64
+ owner.closing_loc
65
+ end
66
+ body_start, body_length = default_owner_body_span(owner, closing)
67
+ details = { byte_start: owner.location.start_offset, byte_length: owner.location.length,
68
+ equal: equal_loc ? true : false, body_start: body_start,
69
+ body_length: body_length, closing_start: closing&.start_offset }
70
+ details.freeze
71
+ end
72
+
73
+ # Prism represents a method-level `rescue`/`ensure` as an implicit
74
+ # BeginNode whose location covers the whole def. Its insertion point is
75
+ # the actual statements (or rescue/ensure clause), not the def keyword.
76
+ def default_owner_body_span(owner, closing)
77
+ body = owner.body
78
+ if body.is_a?(Prism::BeginNode) && body.begin_keyword_loc.nil?
79
+ statements = body.statements
80
+ start = implicit_begin_start(statements, body, closing)
81
+ finish = closing&.start_offset || (body.location.start_offset + body.location.length)
82
+ return [start, start && finish ? [finish - start, 0].max : nil]
83
+ end
84
+
85
+ [body&.location&.start_offset, body&.location&.length]
86
+ end
87
+
88
+ def implicit_begin_start(statements, body, closing)
89
+ [statements&.location&.start_offset, body.rescue_clause&.location&.start_offset,
90
+ body.ensure_clause&.location&.start_offset, closing&.start_offset].compact.first
91
+ end
92
+ end
93
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
94
+ end