branchout 0.1.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 (42) hide show
  1. checksums.yaml +7 -0
  2. data/AGENTS.md +38 -0
  3. data/LICENSE +21 -0
  4. data/README.ja.md +903 -0
  5. data/README.md +586 -0
  6. data/bin/branchout +15 -0
  7. data/examples/arithmetic.rb +24 -0
  8. data/examples/hidden_bug.rb +17 -0
  9. data/examples/inputs/arithmetic.json +10 -0
  10. data/examples/inputs/arithmetic.seed.json +5 -0
  11. data/examples/inputs/hidden_bug.json +9 -0
  12. data/examples/inputs/hidden_bug.seed.json +5 -0
  13. data/examples/inputs/magic_value.json +7 -0
  14. data/examples/inputs/magic_value.seed.json +5 -0
  15. data/examples/inputs/version_parser.json +11 -0
  16. data/examples/inputs/version_parser.seed.json +5 -0
  17. data/examples/magic_value.rb +11 -0
  18. data/examples/version_parser.rb +29 -0
  19. data/lib/branchout/canon.rb +59 -0
  20. data/lib/branchout/cli.rb +302 -0
  21. data/lib/branchout/coverage.rb +9 -0
  22. data/lib/branchout/coverage_session.rb +49 -0
  23. data/lib/branchout/emitters/minitest.rb +92 -0
  24. data/lib/branchout/emitters/rspec.rb +532 -0
  25. data/lib/branchout/emitters/support.rb +1248 -0
  26. data/lib/branchout/emitters.rb +101 -0
  27. data/lib/branchout/engine.rb +303 -0
  28. data/lib/branchout/explore.rb +124 -0
  29. data/lib/branchout/input.rb +215 -0
  30. data/lib/branchout/input_file.rb +125 -0
  31. data/lib/branchout/mutator.rb +124 -0
  32. data/lib/branchout/prism_choice.rb +25 -0
  33. data/lib/branchout/report.rb +449 -0
  34. data/lib/branchout/rng.rb +31 -0
  35. data/lib/branchout/runner.rb +591 -0
  36. data/lib/branchout/sampler.rb +70 -0
  37. data/lib/branchout/target.rb +696 -0
  38. data/lib/branchout/version.rb +8 -0
  39. data/lib/branchout.rb +31 -0
  40. data/skills/branchout/SKILL.md +230 -0
  41. data/skills/branchout/inputs.example.json +10 -0
  42. metadata +114 -0
@@ -0,0 +1,591 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rbconfig"
4
+ require_relative "coverage_session"
5
+
6
+ module Branchout
7
+ # Loads the target under Coverage and runs one input at a time.
8
+ # Branch counters are cleared between calls. Load-time execution is discarded.
9
+ class Runner
10
+ # +thawed+ is true when the input ran with unfrozen Strings, because
11
+ # with frozen ones, as literals in an emitted test are, it raised
12
+ # FrozenError.
13
+ # +varying+ is nil when a second run gave the same value, :whole when
14
+ # the value differed as a whole, or the keys of a returned Hash whose
15
+ # values differed.
16
+ Execution = Struct.new(:input, :value, :error, :lines, :branches, :path_ids, :origin, :thawed, :varying, keyword_init: true)
17
+ # +name+ is the missing name of a NameError, which is stable where its
18
+ # message is not: Ruby 3.4 reworded both NameError and NoMethodError.
19
+ # +message+ has its line breaks folded for reports; a test pins
20
+ # +exact_message+, the message as raised, when that is stable.
21
+ # +message_pattern+ is the source of a Regexp for a message that quotes
22
+ # a backtrace frame: Ruby 3.4 labels a frame 'Box.run' where 3.3 wrote
23
+ # `run', so the pattern keeps the rest and accepts either label.
24
+ ExceptionInfo = Struct.new(:class_name, :message, :loc, :stable_message, :name, :exact_message, :message_pattern,
25
+ keyword_init: true)
26
+ # +arms+ is nil when the callee's file holds no coverage to count from.
27
+ Callee = Struct.new(:name, :loc, :arms, keyword_init: true)
28
+
29
+ # +private_calls+ names the entry and new when they are private or
30
+ # protected, so a test has to call them through send.
31
+ # +const_expression+ names the target class as a test can write it:
32
+ # a private constant has to be reached through const_get.
33
+ # +target_required+ is true when a --require file already loads the
34
+ # target, so a test needs no require of its own for it.
35
+ # +not_counted+ lists the methods in other files that the counted code
36
+ # calls on self, such as a parent class's helper. Their arms are not in
37
+ # the report, so a check moved into one makes arms_total smaller.
38
+ attr_reader :template, :private_calls, :target_required, :const_expression, :not_counted
39
+
40
+ # +requires+ are files the target needs loaded first, such as its
41
+ # library's entry file. They load after coverage starts, so the target
42
+ # is measured even when one of them requires it. Each one's directory
43
+ # joins the end of the load path, after the gems, because a Rails app's
44
+ # config/environment.rb sits beside config/puma.rb. Each +load_paths+
45
+ # directory (-I) goes first, as ruby -I puts it.
46
+ # +host+ is the test that runs explore from inside a suite, which loaded
47
+ # the target and the requires already.
48
+ def initialize(target, timeout:, requires: [], load_paths: [], host: nil)
49
+ @target = target
50
+ @timeout = timeout
51
+ @requires = requires
52
+ @load_paths = load_paths
53
+ @host = host
54
+ @host ? attach : boot
55
+ end
56
+
57
+ # An input runs with frozen Strings, as an emitted test's literals are.
58
+ # Only when that raises FrozenError and unfrozen Strings do not does
59
+ # it keep the unfrozen run, which the emitted test then writes as +"".
60
+ # In a suite, an around that did not run the input in ROLL_BACK, such as
61
+ # RUN_ONLY, keeps what the first run wrote, which a second run would see
62
+ # and no emitted test would, so there the FrozenError stays.
63
+ def execute(input)
64
+ check_receiver(input)
65
+ run = attempt(input, false)
66
+ if run.error&.class_name == "FrozenError" && repeatable?
67
+ thawed = attempt(input, true)
68
+ run = thawed unless thawed.error&.class_name == "FrozenError"
69
+ end
70
+ compare_with_second_run(run) if repeatable?
71
+ run
72
+ end
73
+
74
+ private
75
+
76
+ # A second run starts from the rows the first saw only when the first
77
+ # was rolled back.
78
+ def repeatable?
79
+ !@host || @rolled_back
80
+ end
81
+
82
+ # A value made anew on each run, such as a generated id, a factory
83
+ # sequence, or a SecureRandom token, would fail the emitted test on its
84
+ # first run, so the input runs once more and what differs is not pinned.
85
+ # An error whose message differs is pinned by its class alone.
86
+ # An input that timed out is not run again: a second wait would double
87
+ # the run and its error has no message to compare.
88
+ def compare_with_second_run(run)
89
+ return if run.error&.class_name == "Timeout::Error"
90
+
91
+ again = attempt(run.input, run.thawed)
92
+ if run.error && again.error&.class_name == run.error.class_name
93
+ unless again.error.message == run.error.message && again.error.exact_message == run.error.exact_message
94
+ run.error = run.error.dup.tap do |error|
95
+ error.stable_message = false
96
+ error.exact_message = nil
97
+ error.message_pattern = nil
98
+ end
99
+ end
100
+ elsif run.error || again.error
101
+ run.varying = :whole
102
+ else
103
+ run.varying = Runner.differing(run.value, again.value)
104
+ end
105
+ end
106
+
107
+ # nil when +first+ and +second+ would be pinned the same, the String
108
+ # keys of a Hash whose values differ when only some do, else :whole.
109
+ # A stable value with no literal, such as a Date or a record, is pinned
110
+ # by its class, as same_pin? compares it.
111
+ def self.differing(first, second)
112
+ return nil if same_pin?(first, second)
113
+ return :whole unless first.is_a?(Hash) && second.is_a?(Hash) && first.keys == second.keys &&
114
+ first.keys.all? { |key| Emitters::Support.literal?(key) }
115
+
116
+ keys = first.keys.reject { |key| same_pin?(first[key], second[key]) }
117
+ keys.length == first.length ? :whole : keys.map(&:to_s)
118
+ end
119
+
120
+ # A literal is pinned by its value, anything else by its class and the
121
+ # literals inside it.
122
+ def self.same_pin?(first, second)
123
+ return first.eql?(second) if Emitters::Support.literal?(first) || Emitters::Support.literal?(second)
124
+ return false unless first.class == second.class
125
+
126
+ case first
127
+ when Array then first.length == second.length && first.zip(second).all? { |a, b| same_pin?(a, b) }
128
+ when Hash then first.keys == second.keys && first.all? { |key, item| same_pin?(item, second[key]) }
129
+ when Struct, Data then same_pin?(first.to_h, second.to_h)
130
+ else true
131
+ end
132
+ end
133
+ private_class_method :same_pin?
134
+
135
+ def attempt(input, thawed)
136
+ value, error = @host ? refusing_lets(input) { outcome(input, thawed) } : outcome(input, thawed)
137
+ data = extract(Coverage.result(stop: false, clear: true))
138
+ Execution.new(input: input, value: value, error: error, lines: data[:lines], branches: data[:branches], thawed: thawed)
139
+ end
140
+
141
+ def outcome(input, thawed)
142
+ [invoke(input, thawed), nil]
143
+ rescue Error
144
+ raise
145
+ rescue Exception => e # rubocop:disable Lint/RescueException
146
+ [nil, ExceptionInfo.from(e, @target.display_path, @target.path, fallback_line: @target.ranges.first&.begin)]
147
+ end
148
+
149
+ def refusing_lets(input, &call)
150
+ read, made, result = @host.lets_read_by(&call)
151
+ return result if read.empty?
152
+
153
+ savepoint = " A let an input reads first is made in that input's rolled-back savepoint and kept for the next input." unless made.empty?
154
+ raise Error, "the input #{Canon.json(input.to_h)} reads #{read.map { |name| "let(:#{name})" }.join(", ")} " \
155
+ "of the exploring test, which the emitted test does not have.#{savepoint} Build it in the input's Ruby."
156
+ end
157
+
158
+ def boot
159
+ CoverageSession.install
160
+ load_file = loader_refusing_another_branchout
161
+ loaded_with_tool = target_loaded?
162
+ loading = @target.path
163
+ begin
164
+ quietly do
165
+ measure do
166
+ @load_paths.reverse_each { |dir| $LOAD_PATH.unshift(dir) unless $LOAD_PATH.include?(dir) }
167
+ @requires.each do |path|
168
+ $LOAD_PATH.push(File.dirname(path)) unless $LOAD_PATH.include?(File.dirname(path))
169
+ loading = path
170
+ load_file.(path)
171
+ end
172
+ loading = @target.path
173
+ loaded = target_loaded?
174
+ @target_required = !loaded_with_tool && loaded
175
+ # Loading the file again would define its classes a second
176
+ # time, so an object built from the first ones is not one of them.
177
+ load_file.(@target.path) unless loaded
178
+ end
179
+ end
180
+ rescue Error
181
+ raise
182
+ rescue LoadError, SyntaxError => e
183
+ raise Error, "failed to load #{Target.display_path(loading)}: #{e.message}"
184
+ rescue NameError => e
185
+ hint = " Pass the file that defines #{e.name} with --require." unless e.is_a?(NoMethodError)
186
+ raise Error, "failed to load #{Target.display_path(loading)}: #{e.message.lines.first.strip} (#{e.class}).#{hint}"
187
+ rescue StandardError => e
188
+ raised = e.backtrace_locations&.first
189
+ at = " at #{Target.display_path(raised.path)}:#{raised.lineno}" if raised
190
+ raise Error, "failed to load #{Target.display_path(loading)}: #{e.message.lines.first&.strip} (#{e.class}#{at})"
191
+ end
192
+ unless target_measured?
193
+ raise Error, "#{@target.display_path} loaded before explore set up coverage, so Coverage has no arms for it, " \
194
+ "and loading it again would define its classes a second time."
195
+ end
196
+ resolve
197
+ @not_counted = callees_in_other_files(Coverage.result(stop: false, clear: true))
198
+ @template = extract(Coverage.peek_result)
199
+ end
200
+
201
+ # Loading the target again would run its class body twice, and a
202
+ # Rails model declares validations and callbacks there, so the target
203
+ # is measured only when it loaded after branchout/coverage.
204
+ def attach
205
+ unless Coverage.state == :suspended
206
+ raise Error, "Coverage is #{Coverage.state}, so explore cannot measure #{@target.display_path}. " \
207
+ "Require \"branchout/coverage\" first in the test file that explores, before its helper."
208
+ end
209
+ unless Coverage.peek_result[File.expand_path(@target.path)]&.key?(:branches)
210
+ raise Error, "#{@target.display_path} loaded before branchout/coverage, so Coverage has no arms for it. " \
211
+ "Require \"branchout/coverage\" first in the test file that explores, before its helper, " \
212
+ "and run that file alone.#{rspec_required_first}"
213
+ end
214
+ @target_required = true
215
+ resolve
216
+ @not_counted = callees_in_other_files(Coverage.result(stop: false, clear: true))
217
+ @template = extract(Coverage.peek_result)
218
+ end
219
+
220
+ # rspec requires each file .rspec or -r names before the spec files,
221
+ # so a model those files load is loaded before the explore file's first
222
+ # line, and -r branchout/coverage comes after them too. bundle exec
223
+ # reads RUBYOPT before Bundler sets the load path, so the command names
224
+ # bundler/setup first, or a path or git gem would not be found.
225
+ def rspec_required_first
226
+ return unless defined?(::RSpec) && ::RSpec.respond_to?(:configuration)
227
+
228
+ helpers = ::RSpec.configuration.requires.reject { |name| name.start_with?("branchout") }
229
+ return if helpers.empty?
230
+
231
+ harmless = helpers_done_before_target(helpers)
232
+ " rspec required #{helpers.join(", ")} before the explore file and before any -r branchout/coverage. " \
233
+ "Start Coverage before rspec loads: RUBYOPT=\"-rbundler/setup -rbranchout/coverage\" bundle exec rspec <the explore file>" +
234
+ (harmless.empty? ? "." : ", or rspec --options /dev/null -r #{harmless.join(" -r ")} <the explore file>, " \
235
+ "which leaves out the helpers that loaded it.")
236
+ end
237
+
238
+ # Ruby adds a file to $LOADED_FEATURES when its load ends, so a helper
239
+ # listed before the target finished without loading it. A helper listed
240
+ # after it, such as a rails_helper that booted the app, may be the one
241
+ # that did, and so may every helper rspec required after that one.
242
+ def helpers_done_before_target(helpers)
243
+ target_at = $LOADED_FEATURES.index(File.realpath(@target.path))
244
+ return [] unless target_at
245
+
246
+ helpers.take_while do |name|
247
+ _kind, path = $LOAD_PATH.resolve_feature_path(name)
248
+ at = path && $LOADED_FEATURES.index(path)
249
+ at && at < target_at
250
+ end
251
+ end
252
+
253
+ # Kernel#load, as Rails uses for an initializer, leaves the file out of
254
+ # $LOADED_FEATURES, but coverage holds every file loaded after setup.
255
+ def target_loaded?
256
+ $LOADED_FEATURES.include?(File.realpath(@target.path)) || target_measured?
257
+ end
258
+
259
+ def target_measured?
260
+ wanted = File.expand_path(@target.path)
261
+ Coverage.peek_result.any? { |file, _data| File.expand_path(file) == wanted }
262
+ end
263
+
264
+ # Another checkout's lib/branchout reopens the running tool's classes,
265
+ # so the run would mix two versions. A copy is a file that opens the
266
+ # Branchout namespace; a project file that only shares a file name with
267
+ # one of branchout's is not. The running lib is loaded already, so
268
+ # require never opens its classes again. The check is a closure built
269
+ # before anything loads, because the copy can redefine every method and
270
+ # constant.
271
+ def loader_refusing_another_branchout
272
+ lambda do |path|
273
+ copy = false
274
+ opened = TracePoint.new(:class) do |event|
275
+ name = Module.instance_method(:name).bind_call(event.self).to_s
276
+ copy ||= name == "Branchout" || name.start_with?("Branchout::")
277
+ end
278
+ opened.enable { require path }
279
+ next unless copy
280
+
281
+ raise Error, "#{Target.display_path(path)} loads another copy of branchout, which replaces the running one's classes. " \
282
+ "Run that copy's own bin/branchout instead."
283
+ end
284
+ end
285
+
286
+ def resolve
287
+ expression = nil
288
+ const = @target.const_name.split("::").inject(Object) do |mod, name|
289
+ # Each name is one its lexical parent defines, so a name that
290
+ # Module#constants leaves out is a private constant.
291
+ hidden = !mod.constants.include?(name.to_sym)
292
+ expression = if hidden
293
+ "#{expression || "Object"}.const_get(:#{name})"
294
+ else
295
+ [expression, name].compact.join("::")
296
+ end
297
+ mod.const_get(name)
298
+ end
299
+ @const_expression = expression
300
+ @owner = const
301
+ if @target.singleton
302
+ @method = const.method(@target.method_name)
303
+ @private_calls = const.singleton_class.public_method_defined?(@target.method_name) ? [] : [@target.method_name]
304
+ else
305
+ unless const.is_a?(Class)
306
+ raise Error, "#{@target.const_name} is a module, so its methods have no receiver; " \
307
+ "target #{@target.const_name}.#{@target.method_name}"
308
+ end
309
+
310
+ @const = const
311
+ @method = const.instance_method(@target.method_name)
312
+ @required_new = required_text(const.instance_method(:initialize).parameters)
313
+ @private_calls = [[const.singleton_class, :new], [const, @target.method_name]]
314
+ .reject { |owner, name| owner.public_method_defined?(name) }.map(&:last)
315
+ end
316
+ rescue NameError => e
317
+ raise Error, "cannot resolve #{@target.entry}: #{e.message}"
318
+ end
319
+
320
+ # +loaded+ is the coverage of everything loaded, which holds the arms of
321
+ # each file loaded after coverage started. A method defined in C, in
322
+ # Ruby's own prelude, or by a file loaded before that has no arms to
323
+ # count here.
324
+ def callees_in_other_files(loaded)
325
+ # CoverageSession measures lines and branches, so every file's data
326
+ # is a Hash that holds both.
327
+ branches = loaded.to_h { |file, data| [File.expand_path(file), data[:branches]] }
328
+ on_self = @target.outside_calls.map do |singleton, name|
329
+ -> { [singleton, @owner, singleton ? @owner.method(name) : @owner.instance_method(name)] }
330
+ end
331
+ on_built = @target.typed_calls.map do |namespace, const, name|
332
+ -> { built_class(namespace, const).then { |built| [false, built, built.instance_method(name)] } }
333
+ end
334
+ found = (on_self + on_built).filter_map do |lookup|
335
+ singleton, holder, method = lookup.call
336
+ # An attribute reader or writer has no instructions, so no arms.
337
+ next if RubyVM::InstructionSequence.of(method).nil?
338
+
339
+ # require, require_relative, and the runner load files by absolute
340
+ # path, so the location is already absolute, as coverage keys are.
341
+ file, line = method.source_location
342
+ next if file.start_with?("<internal:")
343
+ next if file == File.expand_path(@target.path)
344
+ next if installed_dirs.any? { |dir| file.start_with?(dir) }
345
+
346
+ lines = Target.def_lines(file, line)
347
+ arms = lines && branches[file]&.sum { |branch_key, arms_of| lines.cover?(branch_key[2]) ? arms_of.length : 0 }
348
+ # A method with no arms hides none.
349
+ next if arms&.zero?
350
+
351
+ [file, line, Callee.new(name: callee_name(method.owner, holder, singleton, method.name), loc: "#{Target.display_path(file)}:#{line}", arms: arms)]
352
+ rescue NameError
353
+ nil
354
+ end
355
+ found.uniq { |file, line, _callee| [file, line] }.sort_by { |file, line, _callee| [file, line] }.map(&:last)
356
+ end
357
+
358
+ # Where installed gems and Ruby's own library live. An app cannot move
359
+ # a check into one, so its methods hide none of the app's arms.
360
+ def installed_dirs
361
+ @installed_dirs ||= [*(Gem.path if defined?(Gem)), RbConfig::CONFIG["rubylibdir"]].map { |dir| File.join(File.expand_path(dir), "") }
362
+ end
363
+
364
+ # The class +const+ names where it is written, inside +namespace+, as
365
+ # Ruby's lexical lookup finds it.
366
+ def built_class(namespace, const)
367
+ scopes = namespace.split("::")
368
+ scopes.length.downto(0).each do |depth|
369
+ name = [*scopes.first(depth), const].join("::")
370
+ return Object.const_get(name) if Object.const_defined?(name)
371
+ end
372
+ Object.const_get(const)
373
+ end
374
+
375
+ # +holder+ is the class the call is made on. A module with no name has
376
+ # only an inspect that carries its address, so it is named by +holder+.
377
+ def callee_name(owner, holder, singleton, name)
378
+ return "#{owner.attached_object.name}.#{name}" if owner.singleton_class?
379
+
380
+ "#{owner.name || "#{holder.name} (an anonymous module)"}#{singleton ? "." : "#"}#{name}"
381
+ end
382
+
383
+ # What an input has to give in "new": the required positional
384
+ # arguments and the required keywords of initialize, with their count,
385
+ # or nil when it needs neither.
386
+ def required_text(parameters)
387
+ count = parameters.count { |kind, _name| kind == :req }
388
+ keywords = parameters.filter_map { |kind, name| "#{name}:" if kind == :keyreq }
389
+ parts = []
390
+ parts << "#{count} argument#{"s" unless count == 1}" if count.positive?
391
+ parts << "the keyword#{"s" if keywords.size > 1} #{keywords.join(", ")}" unless keywords.empty?
392
+ [parts.join(" and "), count + keywords.size] unless parts.empty?
393
+ end
394
+
395
+ def check_receiver(input)
396
+ if @target.singleton
397
+ raise Error, "#{@target.entry} is not an instance method, so its inputs take no \"new\" or \"receiver\"" if input.receiver
398
+ raise Error, "#{@target.entry} is not an instance method, so its inputs take no \"setup\"" unless input.setup.empty?
399
+ elsif input.receiver.nil? && @required_new
400
+ text, count = @required_new
401
+ private_new = " #{@target.const_name}.new is private, so explore and the emitted tests call it through send." if @private_calls.include?(:new)
402
+ raise Error, "#{@target.const_name}#initialize requires #{text}; " \
403
+ "give #{count == 1 ? "it" : "them"} in \"new\" on each input.#{private_new} " \
404
+ "mutate and random fill \"new\" only when #{@target.display_path} defines initialize"
405
+ end
406
+ end
407
+
408
+ # The target's own output is discarded, at load time too, so stdout
409
+ # stays the report.
410
+ def quietly
411
+ previous_out = $stdout
412
+ previous_err = $stderr
413
+ $stdout = StringIO.new
414
+ $stderr = StringIO.new
415
+ yield
416
+ ensure
417
+ $stdout = previous_out
418
+ $stderr = previous_err
419
+ end
420
+
421
+ def invoke(input, thawed)
422
+ return quietly { run_entry(input, thawed) } unless @host
423
+
424
+ value = nil
425
+ Thread.current[:branchout_rolled_back] = false
426
+ begin
427
+ quietly { @host.around.call { value = run_entry(input, thawed) } }
428
+ ensure
429
+ @rolled_back = Thread.current[:branchout_rolled_back]
430
+ end
431
+ value
432
+ end
433
+
434
+ def run_entry(input, thawed)
435
+ scope = new_scope
436
+ run = proc do
437
+ receiver, args, kwargs, block = built_by_input(input) do
438
+ scope.eval(input.before.source, "(input)") if input.before
439
+ receiver = prepared_receiver(input, scope, thawed) unless @target.singleton
440
+ args, kwargs = fresh_arguments(input, scope, thawed)
441
+ [receiver, args, kwargs, (scope.eval("proc #{input.block.source}", "(input)") if input.block)]
442
+ end
443
+ if @target.singleton
444
+ measure { @method.call(*args, **kwargs, &block) }
445
+ else
446
+ measure { @method.bind_call(receiver, *args, **kwargs, &block) }
447
+ end
448
+ end
449
+ if @timeout
450
+ Timeout.timeout(@timeout, &run)
451
+ else
452
+ run.call
453
+ end
454
+ end
455
+
456
+ # FactoryBot's methods that a suite reaches bare only when it includes
457
+ # FactoryBot::Syntax::Methods.
458
+ FACTORY_METHODS = %i[create build build_stubbed attributes_for create_list build_list].freeze
459
+
460
+ # An error raised in the input's own Ruby, before the entry runs and
461
+ # outside the target's file, says nothing about the entry, and a test
462
+ # that pinned it would keep it, so explore stops. An error raised by new
463
+ # or a setup call is the class's own and is pinned.
464
+ def built_by_input(input)
465
+ yield
466
+ rescue Error, Timeout::Error
467
+ raise
468
+ rescue Exception => e # rubocop:disable Lint/RescueException
469
+ raise unless e.backtrace_locations.to_a.any? { |frame| frame.path == "(input)" } &&
470
+ e.backtrace_locations.take_while { |frame| frame.path != "(input)" }.none? { |frame| own_file?(frame.path) }
471
+
472
+ factory = " a bare #{e.name} or build needs FactoryBot::Syntax::Methods in the suite; without it write FactoryBot.#{e.name}." if
473
+ e.is_a?(NoMethodError) && FACTORY_METHODS.include?(e.name)
474
+ raise Error, "the input #{Canon.json(input.to_h)} raised #{e.class} in its own Ruby before #{@target.entry} ran: " \
475
+ "#{ExceptionInfo.one_line(e.message)}. Fix the input#{factory ? ":#{factory}" : "."}"
476
+ end
477
+
478
+ def own_file?(path)
479
+ File.expand_path(path) == File.expand_path(@target.path)
480
+ end
481
+
482
+ # Each input gets a new receiver, as an emitted test does, so one input
483
+ # cannot change the state the next input sees. new and the setup calls
484
+ # run outside coverage: the arms they take are not the entry's.
485
+ # A receiver the input's Ruby builds gets the name the emitted test
486
+ # gives it, so the arguments can be read from it, as in
487
+ # "room.participants.first".
488
+ def prepared_receiver(input, scope, thawed)
489
+ if input.receiver.is_a?(Code)
490
+ receiver = scope.eval(input.receiver.source, "(input)")
491
+ scope.local_variable_set(Emitters::Support.built_receiver_name(@target), receiver)
492
+ else
493
+ new_args, new_kwargs = input.receiver ? fresh_arguments(input.receiver, scope, thawed) : [[], {}]
494
+ receiver = @const.send(:new, *new_args, **new_kwargs)
495
+ end
496
+ input.setup.each do |call|
497
+ call_args, call_kwargs = fresh_arguments(call.arguments, scope, thawed)
498
+ receiver.public_send(call.method_name, *call_args, **call_kwargs)
499
+ end
500
+ receiver
501
+ end
502
+
503
+ # Each call gets its own copy, so the target cannot rewrite the input.
504
+ # Its Strings are frozen, as literals in an emitted test are, unless
505
+ # +thawed+. Code is evaluated here, as an emitted test does, so each
506
+ # call gets its own object.
507
+ def fresh_arguments(input, scope, thawed)
508
+ [built(Marshal.load(Marshal.dump(input.args)), scope, thawed),
509
+ built(Marshal.load(Marshal.dump(input.kwargs)), scope, thawed).transform_keys(&:to_sym)]
510
+ end
511
+
512
+ def built(value, scope, thawed)
513
+ case value
514
+ when Code then scope.eval(value.source, "(input)")
515
+ when String then thawed ? value : value.freeze
516
+ when Array then value.map! { |item| built(item, scope, thawed) }
517
+ when Hash then value.transform_values! { |item| built(item, scope, thawed) }
518
+ else value
519
+ end
520
+ end
521
+
522
+ # Where one input's Ruby runs: the top level, as in an emitted test, or
523
+ # in a suite the test itself, so it reaches the test's factories,
524
+ # fixtures, and helpers. A string eval keeps this method's locals out.
525
+ def new_scope
526
+ @host ? @host.test.instance_eval("binding", __FILE__, __LINE__) : TOPLEVEL_BINDING.eval("binding", __FILE__, __LINE__)
527
+ end
528
+
529
+ def measure
530
+ Coverage.resume
531
+ yield
532
+ ensure
533
+ Coverage.suspend if Coverage.state == :running
534
+ end
535
+
536
+ # The target loaded under CoverageSession, which measures lines and
537
+ # branches, so the result holds both for its file.
538
+ def extract(raw)
539
+ wanted = File.expand_path(@target.path)
540
+ _file, data = raw.find { |file, _data| File.expand_path(file) == wanted }
541
+ branches = data[:branches].select { |branch_key, _arms| @target.covers_line?(branch_key[2]) }
542
+ {lines: data[:lines].dup, branches: branches.to_h { |branch_key, arms| [branch_key.dup, arms.transform_keys(&:dup)] }}
543
+ end
544
+ end
545
+
546
+ class Runner::ExceptionInfo
547
+ def self.from(exception, display_path, real_path, fallback_line: nil)
548
+ raw = exception.message.to_s
549
+ clean = one_line(raw.gsub(/#<[^>]*>/, "#<obj>").gsub(/0x[0-9a-f]+/i, "0xID"))[0, 180]
550
+ pattern = frame_pattern(raw)
551
+ whole = one_line(raw) == clean
552
+ stable = pattern.nil? && whole
553
+ loc = locate(exception, display_path, real_path)
554
+ if loc.nil? && fallback_line && exception.is_a?(Timeout::Error)
555
+ loc = "#{display_path}:#{fallback_line}"
556
+ end
557
+ missing = exception.name if exception.is_a?(NameError) && exception.name.is_a?(Symbol)
558
+ new(class_name: exception.class.name, message: clean, loc: loc, stable_message: stable, name: missing, exact_message: (raw if stable),
559
+ message_pattern: (pattern if whole))
560
+ end
561
+
562
+ # A report line holds the whole message, so each line break, with the
563
+ # blanks around it, becomes one space. Other whitespace is part of
564
+ # what the message says, such as the blank String a FrozenError quotes.
565
+ def self.one_line(text)
566
+ text.gsub(/\A\s*\R|\R\s*\z/, "").gsub(/\s*\R\s*/, " ")
567
+ end
568
+
569
+ FRAME_LABEL = /(:\d+:in )[`'][^`'\n]*'/
570
+
571
+ def self.frame_pattern(raw)
572
+ return nil unless FRAME_LABEL.match?(raw)
573
+
574
+ parts = raw.split(FRAME_LABEL, -1)
575
+ source = parts.each_with_index.map { |part, index| index.odd? ? "#{Regexp.escape(part)}[`'][^`'\\n]*'" : Regexp.escape(part) }
576
+ "\\A#{source.join.gsub("\\ ", " ")}\\z"
577
+ end
578
+
579
+ def self.locate(exception, display_path, real_path)
580
+ Array(exception.backtrace).each do |frame|
581
+ next unless frame =~ /\A(.+):(\d+):/
582
+
583
+ file = Regexp.last_match(1)
584
+ line = Regexp.last_match(2).to_i
585
+ candidate = File.expand_path(file)
586
+ return "#{display_path}:#{line}" if candidate == File.expand_path(real_path)
587
+ end
588
+ nil
589
+ end
590
+ end
591
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Branchout
4
+ class Sampler
5
+ ALPHABET = "abc01.-"
6
+ INT_MIN = -3
7
+ INT_MAX = 12
8
+
9
+ def initialize(target, seed:, count:)
10
+ @target = target
11
+ @rng = Rng.new(seed)
12
+ @count = count
13
+ raise Error, "random count must be >= 0" if count.negative?
14
+ end
15
+
16
+ def inputs
17
+ Array.new(@count) { build }
18
+ end
19
+
20
+ # The entry's values are drawn before the constructor's, so the stream
21
+ # for a class method is the same as before constructors were sampled.
22
+ def build
23
+ entry = draw(@target.parameters)
24
+ return entry if @target.constructor.empty?
25
+
26
+ Input.new(args: entry.args, kwargs: entry.kwargs, receiver: draw(@target.constructor))
27
+ end
28
+
29
+ def draw(parameters)
30
+ args = parameters.select { |param| param.kind == :req || param.kind == :opt }.map { |param| sample(param.inferred_type) }
31
+ kwargs = {}
32
+ parameters.each do |param|
33
+ next unless param.kind == :keyreq
34
+
35
+ kwargs[param.name.to_s] = sample(param.inferred_type)
36
+ end
37
+ Input.new(args: args, kwargs: kwargs)
38
+ end
39
+
40
+ def sample(type)
41
+ case type
42
+ when "int"
43
+ @rng.rand_inclusive(INT_MIN, INT_MAX)
44
+ when "float"
45
+ base = @rng.rand_inclusive(INT_MIN, INT_MAX)
46
+ half = @rng.rand_inclusive(0, 1)
47
+ value = base + (half * 0.5)
48
+ value.zero? ? 0.0 : value
49
+ when "string"
50
+ length = @rng.rand_inclusive(0, 3)
51
+ length.times.map { ALPHABET[@rng.rand_inclusive(0, ALPHABET.length - 1)] }.join
52
+ when "array"
53
+ length = @rng.rand_inclusive(0, 2)
54
+ Array.new(length) { @rng.rand_inclusive(INT_MIN, INT_MAX) }
55
+ when "hash"
56
+ length = @rng.rand_inclusive(0, 2)
57
+ keys = %w[a b c]
58
+ out = {}
59
+ length.times do |index|
60
+ out[keys[index]] = @rng.rand_inclusive(INT_MIN, INT_MAX)
61
+ end
62
+ out
63
+ when "bool"
64
+ @rng.rand_inclusive(0, 1) == 1
65
+ else
66
+ Input.zero(type)
67
+ end
68
+ end
69
+ end
70
+ end