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.
- checksums.yaml +7 -0
- data/AGENTS.md +38 -0
- data/LICENSE +21 -0
- data/README.ja.md +903 -0
- data/README.md +586 -0
- data/bin/branchout +15 -0
- data/examples/arithmetic.rb +24 -0
- data/examples/hidden_bug.rb +17 -0
- data/examples/inputs/arithmetic.json +10 -0
- data/examples/inputs/arithmetic.seed.json +5 -0
- data/examples/inputs/hidden_bug.json +9 -0
- data/examples/inputs/hidden_bug.seed.json +5 -0
- data/examples/inputs/magic_value.json +7 -0
- data/examples/inputs/magic_value.seed.json +5 -0
- data/examples/inputs/version_parser.json +11 -0
- data/examples/inputs/version_parser.seed.json +5 -0
- data/examples/magic_value.rb +11 -0
- data/examples/version_parser.rb +29 -0
- data/lib/branchout/canon.rb +59 -0
- data/lib/branchout/cli.rb +302 -0
- data/lib/branchout/coverage.rb +9 -0
- data/lib/branchout/coverage_session.rb +49 -0
- data/lib/branchout/emitters/minitest.rb +92 -0
- data/lib/branchout/emitters/rspec.rb +532 -0
- data/lib/branchout/emitters/support.rb +1248 -0
- data/lib/branchout/emitters.rb +101 -0
- data/lib/branchout/engine.rb +303 -0
- data/lib/branchout/explore.rb +124 -0
- data/lib/branchout/input.rb +215 -0
- data/lib/branchout/input_file.rb +125 -0
- data/lib/branchout/mutator.rb +124 -0
- data/lib/branchout/prism_choice.rb +25 -0
- data/lib/branchout/report.rb +449 -0
- data/lib/branchout/rng.rb +31 -0
- data/lib/branchout/runner.rb +591 -0
- data/lib/branchout/sampler.rb +70 -0
- data/lib/branchout/target.rb +696 -0
- data/lib/branchout/version.rb +8 -0
- data/lib/branchout.rb +31 -0
- data/skills/branchout/SKILL.md +230 -0
- data/skills/branchout/inputs.example.json +10 -0
- 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
|