branchproof 0.8.0 → 0.10.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 (68) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +37 -0
  3. data/README.md +120 -28
  4. data/doc/Branchproof/DefaultInstrumentation.md +13 -0
  5. data/doc/Branchproof/DefaultRuntime.md +11 -0
  6. data/doc/Branchproof/DefaultSyntax.md +7 -0
  7. data/doc/Branchproof/Evidence.md +3 -0
  8. data/doc/Branchproof/ExceptionInstrumentation.md +9 -0
  9. data/doc/Branchproof/ExceptionRuntime.md +27 -0
  10. data/doc/Branchproof/ExceptionSyntax.md +9 -0
  11. data/doc/Branchproof/ExtendedAlternativeRuntime.md +19 -0
  12. data/doc/Branchproof/Instrumenter.md +1 -1
  13. data/doc/Branchproof/IterationInstrumentation.md +7 -0
  14. data/doc/Branchproof/IterationRuntime.md +21 -0
  15. data/doc/Branchproof/IterationSyntax.md +11 -0
  16. data/doc/Branchproof/Project.md +4 -1
  17. data/doc/Branchproof/RSpecAdapter/ClassRunnerGuard.md +11 -0
  18. data/doc/Branchproof/RSpecAdapter/ContextLifecycle.md +14 -0
  19. data/doc/Branchproof/RSpecAdapter/DefaultDiscovery.md +11 -0
  20. data/doc/Branchproof/RSpecAdapter/ExampleLifecycle.md +11 -0
  21. data/doc/Branchproof/RSpecAdapter/RunnerGuard.md +11 -0
  22. data/doc/Branchproof/RSpecAdapter/UnsupportedRunner.md +12 -0
  23. data/doc/Branchproof/RSpecAdapter.md +47 -0
  24. data/doc/Branchproof/RailsSupport.md +35 -2
  25. data/doc/Branchproof/Report.md +3 -0
  26. data/doc/Branchproof/Runtime.md +59 -1
  27. data/doc/Branchproof/Source.md +7 -1
  28. data/doc/Branchproof/ValueInstrumentation.md +7 -0
  29. data/doc/Branchproof/ValueRuntime.md +26 -0
  30. data/doc/Branchproof/ValueSyntax.md +26 -0
  31. data/doc/Branchproof/Worker.md +8 -1
  32. data/doc/Branchproof.md +24 -3
  33. data/doc/CHANGELOG.md +37 -0
  34. data/doc/README.md +120 -28
  35. data/lib/branchproof/cli.rb +74 -28
  36. data/lib/branchproof/comparison.rb +52 -6
  37. data/lib/branchproof/decision_syntax.rb +19 -9
  38. data/lib/branchproof/default_instrumentation.rb +140 -0
  39. data/lib/branchproof/default_runtime.rb +16 -0
  40. data/lib/branchproof/default_syntax.rb +94 -0
  41. data/lib/branchproof/evidence.rb +87 -1
  42. data/lib/branchproof/exception_instrumentation.rb +96 -0
  43. data/lib/branchproof/exception_runtime.rb +41 -0
  44. data/lib/branchproof/exception_syntax.rb +157 -0
  45. data/lib/branchproof/extended_alternative_runtime.rb +23 -0
  46. data/lib/branchproof/flow_instrumentation.rb +36 -7
  47. data/lib/branchproof/focused_report.rb +5 -0
  48. data/lib/branchproof/instrumenter.rb +50 -5
  49. data/lib/branchproof/iteration_instrumentation.rb +66 -0
  50. data/lib/branchproof/iteration_runtime.rb +79 -0
  51. data/lib/branchproof/iteration_syntax.rb +75 -0
  52. data/lib/branchproof/loader.rb +6 -1
  53. data/lib/branchproof/project.rb +49 -2
  54. data/lib/branchproof/rails_support.rb +101 -0
  55. data/lib/branchproof/report.rb +21 -1
  56. data/lib/branchproof/rspec_adapter.rb +423 -0
  57. data/lib/branchproof/runtime.rb +14 -0
  58. data/lib/branchproof/saved_report.rb +9 -2
  59. data/lib/branchproof/source.rb +59 -32
  60. data/lib/branchproof/value_instrumentation.rb +35 -0
  61. data/lib/branchproof/value_runtime.rb +73 -0
  62. data/lib/branchproof/value_syntax.rb +116 -0
  63. data/lib/branchproof/version.rb +1 -1
  64. data/lib/branchproof/worker.rb +75 -15
  65. data/lib/branchproof.rb +1 -0
  66. data/llms.txt +24 -3
  67. data/sig/branchproof.rbs +45 -1
  68. metadata +36 -2
@@ -33,10 +33,10 @@ module Branchproof
33
33
 
34
34
  inventory = build_inventory(options)
35
35
  evidence = empty_evidence(inventory, options)
36
- baseline = if options[:tests].empty?
37
- { status: "INCOMPLETE", executed_tests: 0, failed_tests: 0, skipped_tests: 0, finalized: false }
38
- else
36
+ baseline = if run_worker?(options)
39
37
  run_worker(options, inventory, evidence)
38
+ else
39
+ { status: "INCOMPLETE", executed_tests: 0, failed_tests: 0, skipped_tests: 0, finalized: false }
40
40
  end
41
41
  if value(baseline, :evidence)
42
42
  merge_status = evidence.merge(snapshot: value(baseline, :evidence))
@@ -93,7 +93,7 @@ module Branchproof
93
93
  def help
94
94
  @stdout.write(<<~HELP)
95
95
  Usage:
96
- branchproof analyze [SOURCE_GLOB ...] [--test TEST_GLOB] [--project auto|ruby|rails]
96
+ branchproof analyze [SOURCE_GLOB ...] [--test TEST_GLOB] [--project auto|ruby|rails] [--framework auto|minitest|rspec]
97
97
  [--view decisions|conditions|tests|decision-tables] [--level 1|2|3] [--missing-only]
98
98
  [--format terminal|json] [--output PATH] [--limits PATH] [--no-reachability] [-- RUNNER_ARGS]
99
99
  branchproof report SNAPSHOT [--view decisions|conditions|tests|decision-tables]
@@ -205,12 +205,24 @@ module Branchproof
205
205
  source = value(test, :source) || {}
206
206
  [value(test, :id), { relative_path: relative_path(value(source, :path), root), line: value(source, :line) }]
207
207
  end
208
- { captured_at: Time.now.utc.iso8601, requested_level: options[:level], project_kind: options[:project][:kind],
208
+ project_metadata = value(baseline, :project) || options[:project]
209
+ selected_test_files = value(baseline, :selected_test_files)
210
+ test_files = selected_test_files || options[:tests]
211
+ metadata = {
212
+ captured_at: Time.now.utc.iso8601, requested_level: options[:level], project_kind: options[:project][:kind],
209
213
  project_root: root, source_patterns: options[:source_patterns].map { |path| relative_path(path, root) },
210
214
  test_patterns: options[:test_patterns].map { |path| relative_path(path, root) },
211
- test_files: options[:tests].map { |path| relative_path(path, root) }, runner_args: options[:runner_args],
215
+ test_files: Array(test_files).map { |path| relative_path(path, root) }, runner_args: options[:runner_args],
212
216
  seed: value(baseline, :seed), limits: options[:limits], test_locations: locations,
213
- reachability: options[:reachability] }
217
+ reachability: options[:reachability]
218
+ }
219
+ %i[selected_test_files selected_example_ids].each do |key|
220
+ metadata[key] = value(baseline, key) if value(baseline, key)
221
+ end
222
+ %i[framework framework_version rspec_rails_version rails_version].each do |key|
223
+ metadata[key] = value(project_metadata, key) if value(project_metadata, key)
224
+ end
225
+ metadata
214
226
  end
215
227
 
216
228
  def relative_path(path, root)
@@ -228,7 +240,7 @@ module Branchproof
228
240
  args = args[0...delimiter] if delimiter
229
241
  options = { level: 3, format: :terminal, output: nil, tests: [], source_paths: [], limits: Limits.default,
230
242
  runner_args: runner_args, project: nil, missing_only: false, view: :decisions,
231
- reachability: true }
243
+ reachability: true, project_mode: "auto", framework: "auto", explicit_tests: false }
232
244
  until args.empty?
233
245
  token = args.shift
234
246
  case token
@@ -255,11 +267,14 @@ module Branchproof
255
267
  when "--test"
256
268
  options[:tests] << args.shift
257
269
  raise ArgumentError, "--test requires a glob" if options[:tests].last.nil? || options[:tests].last.empty?
258
- when "--project"
259
- mode = args.shift
260
- raise ArgumentError, "--project requires auto, ruby, or rails" if mode.nil? || mode.empty?
261
270
 
262
- options[:project] = Project.new(root: Dir.pwd, mode: mode).to_h
271
+ options[:explicit_tests] = true
272
+ when "--framework"
273
+ options[:framework] = args.shift
274
+ raise ArgumentError, "--framework requires auto, minitest, or rspec" if options[:framework].nil? || options[:framework].empty?
275
+ when "--project"
276
+ options[:project_mode] = args.shift
277
+ raise ArgumentError, "--project requires auto, ruby, or rails" if options[:project_mode].nil? || options[:project_mode].empty?
263
278
  when "--limits"
264
279
  limits_path = args.shift
265
280
  raise ArgumentError, "--limits requires a readable JSON path" unless limits_path && File.file?(limits_path)
@@ -273,16 +288,16 @@ module Branchproof
273
288
  options[:source_paths] << token
274
289
  end
275
290
  end
291
+ options[:project] = Project.new(root: Dir.pwd, mode: options[:project_mode], framework: options[:framework]).to_h
276
292
  validate_view!(options)
277
293
  if options[:missing_only] && (options[:format] != :terminal || options[:level] == 1)
278
294
  raise ArgumentError, "--missing-only requires terminal format and level 2 or 3"
279
295
  end
280
296
 
281
- options[:project] ||= Project.new(root: Dir.pwd, mode: "auto").to_h
282
297
  options[:source_paths] = default_sources if options[:source_paths].empty?
283
- options[:tests] = default_tests if options[:tests].empty?
298
+ options[:tests] = default_tests(options[:project]) if options[:tests].empty?
284
299
  options[:source_patterns] = options[:source_paths].dup
285
- options[:test_patterns] = argv.include?("--test") ? options[:tests].dup : %w[test/**/*_test.rb test/**/test_*.rb]
300
+ options[:test_patterns] = options[:explicit_tests] ? options[:tests].dup : options[:project][:test_patterns]
286
301
  options[:tests] = expand_paths(options[:tests], root: options[:project][:root])
287
302
  options
288
303
  end
@@ -315,18 +330,10 @@ module Branchproof
315
330
  begin
316
331
  directory = Dir.mktmpdir("branchproof-run-")
317
332
  config = File.join(directory, "request.json")
318
- payload = {
319
- inventory: inventory, limits: options[:limits], run_id: evidence.run_id,
320
- test_files: options[:tests], runner_args: options[:runner_args], project: options[:project],
321
- result_path: File.join(directory, "result.json"), marker_path: File.join(directory, "complete.marker")
322
- }
333
+ payload = worker_payload(options, inventory, evidence, directory)
323
334
  File.binwrite(config, JSON.generate(normalize(payload)))
324
335
  script = "require 'branchproof'; exit(Branchproof::Worker.child_process(ARGV.fetch(0)).to_i)"
325
- child_stdout, stderr, child_status = Open3.capture3(options[:project][:environment], RbConfig.ruby, "-I",
326
- File.expand_path("..", __dir__), "-e", script, config,
327
- chdir: options[:project][:root])
328
- @stderr.write(child_stdout) unless child_stdout.to_s.empty?
329
- @stderr.write(stderr) unless stderr.to_s.empty?
336
+ stderr, child_status = stream_worker(options, script, config)
330
337
  unless File.file?(payload[:marker_path]) && File.file?(payload[:result_path])
331
338
  return { status: "ERROR", executed_tests: 0, failed_tests: 0, skipped_tests: 0, finalized: false,
332
339
  diagnostics: [{ code: "worker_incomplete", severity: "error", message: stderr.to_s.strip }] }
@@ -346,6 +353,30 @@ module Branchproof
346
353
  diagnostics: [{ code: "runner", severity: "error", message: e.message }] }
347
354
  end
348
355
 
356
+ def stream_worker(options, script, config)
357
+ diagnostic_output = +""
358
+ Open3.popen3(options[:project][:environment], RbConfig.ruby, "-I",
359
+ File.expand_path("..", __dir__), "-e", script, config,
360
+ chdir: options[:project][:root]) do |input, output, errors, child|
361
+ input.close
362
+ streams = [output, errors]
363
+ until streams.empty?
364
+ IO.select(streams).first.each do |stream|
365
+ chunk = stream.read_nonblock(16_384, exception: false)
366
+ if chunk.nil?
367
+ streams.delete(stream)
368
+ elsif chunk != :wait_readable
369
+ @stderr.write(chunk)
370
+ @stderr.flush if @stderr.respond_to?(:flush)
371
+ diagnostic_output << chunk
372
+ diagnostic_output = diagnostic_output.byteslice(-16_384, 16_384) if diagnostic_output.bytesize > 16_384
373
+ end
374
+ end
375
+ end
376
+ [diagnostic_output, child.value]
377
+ end
378
+ end
379
+
349
380
  def output_report(report, options)
350
381
  created = false
351
382
  if options[:output]
@@ -362,6 +393,19 @@ module Branchproof
362
393
  File.unlink(temporary) if created && temporary && File.file?(temporary)
363
394
  end
364
395
 
396
+ def run_worker?(options)
397
+ !options[:tests].empty? || options[:project][:framework].to_s == "rspec"
398
+ end
399
+
400
+ def worker_payload(options, inventory, evidence, directory)
401
+ {
402
+ inventory: inventory, limits: options[:limits], run_id: evidence.run_id,
403
+ test_files: options[:tests], runner_args: options[:runner_args], project: options[:project],
404
+ test_selection_explicit: options[:explicit_tests],
405
+ result_path: File.join(directory, "result.json"), marker_path: File.join(directory, "complete.marker")
406
+ }
407
+ end
408
+
365
409
  def report_string(report, format)
366
410
  buffer = StringIO.new
367
411
  report.write(io: buffer, format: format)
@@ -377,8 +421,9 @@ module Branchproof
377
421
  %w[lib/**/*.rb app/**/*.rb]
378
422
  end
379
423
 
380
- def default_tests
381
- candidates = Dir.glob("test/**/*_test.rb", base: Dir.pwd) + Dir.glob("test/**/test_*.rb", base: Dir.pwd)
424
+ def default_tests(project = nil)
425
+ project ||= Project.new(root: Dir.pwd, mode: "auto", framework: "auto").to_h
426
+ candidates = Array(project[:test_patterns]).flat_map { |pattern| Dir.glob(pattern, base: project[:root]) }
382
427
  candidates.map! { |path| File.expand_path(path, Dir.pwd) }
383
428
  candidates.reject { |path| default_test_excluded?(path) }.uniq.sort
384
429
  end
@@ -387,7 +432,8 @@ module Branchproof
387
432
  relative = Pathname.new(path).relative_path_from(Pathname.new(Dir.pwd)).to_s
388
433
  segments = relative.split(File::SEPARATOR)
389
434
  basename = File.basename(path)
390
- basename == "test_helper.rb" || segments.include?("support") || segments.include?("fixtures")
435
+ %w[test_helper.rb spec_helper.rb rails_helper.rb].include?(basename) ||
436
+ segments.include?("support") || segments.include?("fixtures")
391
437
  end
392
438
 
393
439
  def expand_paths(paths, root: Dir.pwd)
@@ -2,6 +2,7 @@
2
2
 
3
3
  # rubocop:disable Metrics/ClassLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
4
4
  require "json"
5
+ require "pathname"
5
6
 
6
7
  module Branchproof
7
8
  # Compares two complete report documents without loading or executing the project.
@@ -369,9 +370,17 @@ module Branchproof
369
370
  tests = Array(value(value(document, :observations), :tests)).to_h { |test| [value(test, :id).to_s, test] }
370
371
  rows = index.tests.to_h { |test| [test[:id].to_s, test] }
371
372
  keys = tests.to_h do |id, test|
372
- parts = %i[adapter class_name method_name].map { |key| value(test, key) }
373
- parts << rows.dig(id, :relative_path)
374
- [id, parts.all? { |part| part.is_a?(String) && !part.empty? } ? parts : nil]
373
+ adapter = value(test, :adapter).to_s
374
+ path = rows.dig(id, :relative_path)
375
+ key = if adapter.downcase == "rspec"
376
+ example_id = value(test, :example_id).to_s
377
+ # Scoped IDs are positional selectors, valid only within the same spec revision.
378
+ [adapter, rspec_example_path(document, example_id) || path, rspec_scope(example_id),
379
+ value(test, :name), path, value(test, :spec_digest), value(test, :source_digest)]
380
+ else
381
+ [adapter, value(test, :class_name), value(test, :method_name), path]
382
+ end
383
+ [id, key.all? { |part| part.is_a?(String) && !part.empty? } ? key : nil]
375
384
  end
376
385
  results = Array(value(value(document, :analysis), :decisions)).flat_map do |decision|
377
386
  Array(value(decision, :condition_results))
@@ -447,6 +456,13 @@ module Branchproof
447
456
  if context(@before)[:test_keys].values.sort_by(&:to_s) != context(@after)[:test_keys].values.sort_by(&:to_s)
448
457
  changes << "test population differs"
449
458
  end
459
+ if [@before, @after].any? do |document|
460
+ context(document)[:tests].any? do |id, test|
461
+ value(test, :adapter).to_s.downcase == "rspec" && context(document)[:test_keys][id].nil?
462
+ end
463
+ end
464
+ changes << "RSpec example identity is uncertain: missing spec revision or selector evidence"
465
+ end
450
466
  changes << "Deltas describe observed runs; nondeterminism and external environment changes are not ruled out."
451
467
  changes
452
468
  end
@@ -454,9 +470,10 @@ module Branchproof
454
470
  def comparability_reasons(changed_paths)
455
471
  reasons = []
456
472
  reasons << "source files changed: #{changed_paths.sort.join(", ")}" unless changed_paths.empty?
457
- %i[project_kind runner_args source_patterns test_patterns limits runtime].each do |key|
458
- left = metadata(@before, key)
459
- right = metadata(@after, key)
473
+ %i[project_kind framework framework_version rspec_rails_version runner_args source_patterns test_patterns limits
474
+ runtime].each do |key|
475
+ left = key == :framework ? comparison_framework(@before) : metadata(@before, key)
476
+ right = key == :framework ? comparison_framework(@after) : metadata(@after, key)
460
477
  if key == :runner_args
461
478
  left = comparable_runner_args(left)
462
479
  right = comparable_runner_args(right)
@@ -467,6 +484,35 @@ module Branchproof
467
484
  reasons
468
485
  end
469
486
 
487
+ def comparison_framework(document)
488
+ explicit = metadata(document, :framework)
489
+ return explicit unless explicit.nil? || explicit.to_s.empty?
490
+
491
+ adapters = Array(value(value(document, :observations), :tests)).filter_map do |test|
492
+ adapter = value(test, :adapter).to_s.downcase
493
+ adapter unless adapter.empty? || adapter == "unknown"
494
+ end.uniq
495
+ adapters.length == 1 ? adapters.first : nil
496
+ end
497
+
498
+ def rspec_scope(example_id)
499
+ match = example_id.to_s.match(/(\[[^\]]+\])\z/)
500
+ match && match[1]
501
+ end
502
+
503
+ def rspec_example_path(document, example_id)
504
+ path = example_id.to_s.sub(/\[[^\]]+\]\z/, "")
505
+ return nil if path.empty?
506
+ return path.sub(%r{\A\./}, "") unless path.start_with?("/")
507
+
508
+ root = value(value(document, :source_inventory), :root) || metadata(document, :project_root)
509
+ return nil unless root
510
+
511
+ Pathname.new(path).relative_path_from(Pathname.new(root.to_s)).to_s
512
+ rescue ArgumentError
513
+ nil
514
+ end
515
+
470
516
  def source_selection(document)
471
517
  value(value(document, :run_metadata), :source_patterns)
472
518
  end
@@ -117,7 +117,8 @@ module Branchproof
117
117
  encoding),
118
118
  range: byte_range(candidate.location, splat_node?(candidate) ? "splat" : nil),
119
119
  byte_start: candidate.location.start_offset,
120
- byte_length: candidate.location.length
120
+ byte_length: candidate.location.length,
121
+ splat: splat_node?(candidate)
121
122
  }
122
123
  end
123
124
  branches << {
@@ -142,9 +143,6 @@ module Branchproof
142
143
  empty: statements_empty?(else_clause.statements) }
143
144
  end
144
145
  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
146
  instrumentation = {
149
147
  type: "case",
150
148
  range: byte_range(node.location),
@@ -171,7 +169,6 @@ module Branchproof
171
169
  guarded = pattern.is_a?(Prism::IfNode) || pattern.is_a?(Prism::UnlessNode)
172
170
  guard = guarded ? pattern.predicate : nil
173
171
  pattern_node = guarded ? pattern.statements&.body&.first : pattern
174
- reasons << "unsupported_pattern_guard" if guard
175
172
  candidate_node = pattern_node || pattern
176
173
  candidates << {
177
174
  expression: text_value(bytes.byteslice(candidate_node.location.start_offset, candidate_node.location.length),
@@ -233,16 +230,28 @@ module Branchproof
233
230
  rhs = node.value
234
231
  operator = bytes.byteslice(node.operator_loc.start_offset, node.operator_loc.length)
235
232
  assignment_context = operator == "||=" ? "or_assignment" : "and_assignment"
236
- reasons = safe_navigation_assignment?(node) ? ["unsupported_assignment_target"] : []
233
+ safe_navigation = safe_navigation_assignment?(node)
234
+ reasons = []
237
235
  instrumentation = {
238
236
  type: "assignment",
239
237
  range: byte_range(node.location),
240
238
  rhs: byte_range(rhs.location),
241
239
  operator: operator,
242
240
  rhs_path: 1,
243
- skipped_path: 0
241
+ skipped_path: 0,
242
+ receiver: safe_navigation && byte_range(node.receiver.location),
243
+ alternative_count: safe_navigation ? 3 : 2
244
244
  }
245
- alternatives = if operator == "||="
245
+ alternatives = if safe_navigation
246
+ skipped = operator == "||=" ? "LHS truthy; RHS skipped" : "LHS falsey; RHS skipped"
247
+ executed = operator == "||=" ? "LHS falsey; RHS executed" : "LHS truthy; RHS executed"
248
+ [{ expression: "receiver nil", byte_start: node.receiver.location.start_offset,
249
+ byte_length: node.receiver.location.length },
250
+ { expression: skipped, byte_start: node.location.start_offset,
251
+ byte_length: node.location.length },
252
+ { expression: executed, byte_start: node.location.start_offset,
253
+ byte_length: node.location.length }]
254
+ elsif operator == "||="
246
255
  [{ expression: "LHS truthy; RHS skipped", byte_start: node.location.start_offset,
247
256
  byte_length: node.location.length },
248
257
  { expression: "LHS falsey; RHS executed", byte_start: node.location.start_offset,
@@ -253,7 +262,8 @@ module Branchproof
253
262
  { expression: "LHS truthy; RHS executed", byte_start: node.location.start_offset,
254
263
  byte_length: node.location.length }]
255
264
  end
256
- ["implicit", assignment_context, alternatives, instrumentation, unsupported_reasons(node, bytes) + reasons]
265
+ [safe_navigation ? "multiway" : "implicit", assignment_context, alternatives,
266
+ instrumentation, unsupported_reasons(node, bytes) + reasons]
257
267
  end
258
268
 
259
269
  def rescue_details(node, bytes, encoding)
@@ -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