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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +37 -0
- data/README.md +120 -28
- data/doc/Branchproof/DefaultInstrumentation.md +13 -0
- data/doc/Branchproof/DefaultRuntime.md +11 -0
- data/doc/Branchproof/DefaultSyntax.md +7 -0
- data/doc/Branchproof/Evidence.md +3 -0
- data/doc/Branchproof/ExceptionInstrumentation.md +9 -0
- data/doc/Branchproof/ExceptionRuntime.md +27 -0
- data/doc/Branchproof/ExceptionSyntax.md +9 -0
- data/doc/Branchproof/ExtendedAlternativeRuntime.md +19 -0
- data/doc/Branchproof/Instrumenter.md +1 -1
- data/doc/Branchproof/IterationInstrumentation.md +7 -0
- data/doc/Branchproof/IterationRuntime.md +21 -0
- data/doc/Branchproof/IterationSyntax.md +11 -0
- data/doc/Branchproof/Project.md +4 -1
- data/doc/Branchproof/RSpecAdapter/ClassRunnerGuard.md +11 -0
- data/doc/Branchproof/RSpecAdapter/ContextLifecycle.md +14 -0
- data/doc/Branchproof/RSpecAdapter/DefaultDiscovery.md +11 -0
- data/doc/Branchproof/RSpecAdapter/ExampleLifecycle.md +11 -0
- data/doc/Branchproof/RSpecAdapter/RunnerGuard.md +11 -0
- data/doc/Branchproof/RSpecAdapter/UnsupportedRunner.md +12 -0
- data/doc/Branchproof/RSpecAdapter.md +47 -0
- data/doc/Branchproof/RailsSupport.md +35 -2
- data/doc/Branchproof/Report.md +3 -0
- data/doc/Branchproof/Runtime.md +59 -1
- data/doc/Branchproof/Source.md +7 -1
- data/doc/Branchproof/ValueInstrumentation.md +7 -0
- data/doc/Branchproof/ValueRuntime.md +26 -0
- data/doc/Branchproof/ValueSyntax.md +26 -0
- data/doc/Branchproof/Worker.md +8 -1
- data/doc/Branchproof.md +24 -3
- data/doc/CHANGELOG.md +37 -0
- data/doc/README.md +120 -28
- data/lib/branchproof/cli.rb +74 -28
- data/lib/branchproof/comparison.rb +52 -6
- data/lib/branchproof/decision_syntax.rb +19 -9
- data/lib/branchproof/default_instrumentation.rb +140 -0
- data/lib/branchproof/default_runtime.rb +16 -0
- data/lib/branchproof/default_syntax.rb +94 -0
- data/lib/branchproof/evidence.rb +87 -1
- data/lib/branchproof/exception_instrumentation.rb +96 -0
- data/lib/branchproof/exception_runtime.rb +41 -0
- data/lib/branchproof/exception_syntax.rb +157 -0
- data/lib/branchproof/extended_alternative_runtime.rb +23 -0
- data/lib/branchproof/flow_instrumentation.rb +36 -7
- data/lib/branchproof/focused_report.rb +5 -0
- data/lib/branchproof/instrumenter.rb +50 -5
- data/lib/branchproof/iteration_instrumentation.rb +66 -0
- data/lib/branchproof/iteration_runtime.rb +79 -0
- data/lib/branchproof/iteration_syntax.rb +75 -0
- data/lib/branchproof/loader.rb +6 -1
- data/lib/branchproof/project.rb +49 -2
- data/lib/branchproof/rails_support.rb +101 -0
- data/lib/branchproof/report.rb +21 -1
- data/lib/branchproof/rspec_adapter.rb +423 -0
- data/lib/branchproof/runtime.rb +14 -0
- data/lib/branchproof/saved_report.rb +9 -2
- data/lib/branchproof/source.rb +59 -32
- data/lib/branchproof/value_instrumentation.rb +35 -0
- data/lib/branchproof/value_runtime.rb +73 -0
- data/lib/branchproof/value_syntax.rb +116 -0
- data/lib/branchproof/version.rb +1 -1
- data/lib/branchproof/worker.rb +75 -15
- data/lib/branchproof.rb +1 -0
- data/llms.txt +24 -3
- data/sig/branchproof.rbs +45 -1
- metadata +36 -2
data/lib/branchproof/cli.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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:
|
|
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[:
|
|
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] =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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
|
|
458
|
-
|
|
459
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|