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,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "emitters/support"
|
|
4
|
+
require_relative "emitters/minitest"
|
|
5
|
+
require_relative "emitters/rspec"
|
|
6
|
+
|
|
7
|
+
module Branchout
|
|
8
|
+
module Emitters
|
|
9
|
+
FORMATS = %w[minitest rspec].freeze
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def resolve(explicit, path)
|
|
14
|
+
if explicit
|
|
15
|
+
unless FORMATS.include?(explicit)
|
|
16
|
+
raise Error, "format must be minitest or rspec"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
return explicit
|
|
20
|
+
end
|
|
21
|
+
return "rspec" if path.to_s.end_with?("_spec.rb")
|
|
22
|
+
|
|
23
|
+
"minitest"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Returns a warning for each file the test requires by a path out of its
|
|
27
|
+
# project, and for each path of this machine that the test passes
|
|
28
|
+
# as it is (Support.machine_paths), or pins in a returned value or an
|
|
29
|
+
# error message, for the caller to print.
|
|
30
|
+
def write(format, result, target, destination, require_by_name: false, pin_keys: [])
|
|
31
|
+
pins = project_pins(result.pins, destination)
|
|
32
|
+
Support.write_file(destination, render(format, result, target, destination, require_by_name: require_by_name, pin_keys: pin_keys))
|
|
33
|
+
varying = result.pins.select(&:varying).map { |pin| varying_warning(pin, pin_keys) }
|
|
34
|
+
passed = pins.flat_map { |pin| Support.machine_paths(pin.input) }.uniq
|
|
35
|
+
pinned = pins.flat_map do |pin|
|
|
36
|
+
message = pin.error.exact_message || pin.error.message if pin.error&.stable_message
|
|
37
|
+
Support.machine_paths_in([Support.pinned_value(pin.value, Support.keys_for(pin, pin_keys)), message])
|
|
38
|
+
end
|
|
39
|
+
existing, missing = passed.partition { |path| File.exist?(path) }
|
|
40
|
+
outside = Support.required_outside_project(result.requires, (target.path unless result.target_required), destination,
|
|
41
|
+
by_name: require_by_name, load_paths: result.load_paths)
|
|
42
|
+
varying + outside.map do |path|
|
|
43
|
+
"branchout: the emitted test requires #{path} by its path, outside the test's project, so the test runs only where " \
|
|
44
|
+
"that file sits on this machine. Emit the test under that file's project, or #{require_by_name ? "pass -I with a directory that holds it" : "pass --require-by-name"} " \
|
|
45
|
+
"to require it through the load path."
|
|
46
|
+
end + existing.map do |path|
|
|
47
|
+
"branchout: the emitted test passes #{path}, a file of this machine outside the test's project, so the test passes " \
|
|
48
|
+
"only here. Put the file under the project, and the test names it from its own directory."
|
|
49
|
+
end + (missing - pinned).map do |path|
|
|
50
|
+
"branchout: the emitted test passes #{path}, a path of this machine outside the test's project that does not exist, " \
|
|
51
|
+
"so the test holds this machine's directories. Put the path under the project, and the test names it from its own directory."
|
|
52
|
+
end + (pinned.uniq - existing).map do |path|
|
|
53
|
+
"branchout: the emitted test pins #{path}, a path of this machine outside the test's project, in a returned value " \
|
|
54
|
+
"or an error message, so the test passes only here. Give #{target.entry} a path under the project."
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# +pin_keys+ names the keys a returned Hash pins; empty pins it whole.
|
|
59
|
+
# +from+ is the directory the test's runner starts in.
|
|
60
|
+
# A file with no pin says whether no input ran or each one differed
|
|
61
|
+
# between two runs.
|
|
62
|
+
def render(format, result, target, destination, require_by_name: false, pin_keys: [], from: Dir.pwd)
|
|
63
|
+
pins = project_pins(result.pins, destination)
|
|
64
|
+
emitter_for(format).render(result.dup.tap { |copy| copy.pins = pins }, target, destination,
|
|
65
|
+
require_by_name: require_by_name, pin_keys: pin_keys, from: from,
|
|
66
|
+
all_varied: pins.empty? && result.pins.any?)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def varying_warning(pin, pin_keys)
|
|
70
|
+
return "branchout: #{pin.input.text} returned a value that differed between two runs, so it has no test." if pin.varying == :whole
|
|
71
|
+
|
|
72
|
+
"branchout: #{pin.input.text} returned a Hash whose #{pin.varying.join(", ")} differed between two runs, " \
|
|
73
|
+
"so its test pins only #{Support.keys_for(pin, pin_keys).join(", ")}."
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Each pin with the paths under the test's project in its input, its
|
|
77
|
+
# returned value, and its error message written from the test's file.
|
|
78
|
+
# A pin whose value differed as a whole between two runs is left out.
|
|
79
|
+
def project_pins(pins, destination)
|
|
80
|
+
pins.reject { |pin| pin.varying == :whole }.map do |pin|
|
|
81
|
+
pin.dup.tap do |copy|
|
|
82
|
+
copy.input = Support.project_paths(pin.input, destination)
|
|
83
|
+
copy.value = Support.project_value(pin.value, destination)
|
|
84
|
+
next unless pin.error
|
|
85
|
+
|
|
86
|
+
copy.error = pin.error.dup.tap do |error|
|
|
87
|
+
error.message = Support.project_value(error.message, destination)
|
|
88
|
+
error.exact_message = Support.project_value(error.exact_message, destination)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def emitter_for(format)
|
|
95
|
+
case resolve(format, nil)
|
|
96
|
+
when "minitest" then Minitest
|
|
97
|
+
when "rspec" then RSpec
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Branchout
|
|
4
|
+
class Engine
|
|
5
|
+
STRATEGY_ORDER = %w[inputs mutate random].freeze
|
|
6
|
+
|
|
7
|
+
Result = Struct.new(
|
|
8
|
+
:command, :target, :entry, :seed, :strategies, :random, :mutate_cap, :timeout,
|
|
9
|
+
:inputs_ran, :mutate_ran, :random_ran, :executions,
|
|
10
|
+
:covered, :frontier, :exceptions, :pins, :requires, :load_paths, :target_required, :private_calls, :const_expression, :repeated_inputs, :not_counted,
|
|
11
|
+
:operators, :memos, :boundaries,
|
|
12
|
+
:test_class, :spec_type,
|
|
13
|
+
keyword_init: true
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
# +pinned+ is false for a covered arm whose every input differed as a
|
|
17
|
+
# whole between two runs, so no emitted test checks it.
|
|
18
|
+
Arm = Struct.new(
|
|
19
|
+
:id, :loc, :line, :column, :cond, :sense, :input, :parent, :nearest, :path,
|
|
20
|
+
:covered, :pinned,
|
|
21
|
+
keyword_init: true
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
# +covers+ lists the arms the input took, in source order.
|
|
25
|
+
# +thawed+ marks an input that ran with unfrozen Strings (Runner::Execution).
|
|
26
|
+
# +varying+ is Runner::Execution's: what differed between two runs.
|
|
27
|
+
# +listed_at+ is the input's index in the inputs file, or nil for an
|
|
28
|
+
# input branchout made.
|
|
29
|
+
Pin = Struct.new(:input, :value, :error, :covers, :thawed, :varying, :listed_at, keyword_init: true)
|
|
30
|
+
Cover = Struct.new(:line, :column, :text, keyword_init: true)
|
|
31
|
+
Finding = Struct.new(:class_name, :message, :loc, :input, :stable_message, keyword_init: true)
|
|
32
|
+
|
|
33
|
+
def self.explore(target:, entry:, user_inputs: [], type_overrides: nil, seed: 0, strategies: STRATEGY_ORDER,
|
|
34
|
+
random: 16, mutate_cap: 256, timeout: 1, command: "explore", requires: [], load_paths: [], host: nil)
|
|
35
|
+
new(
|
|
36
|
+
target: target, entry: entry, user_inputs: user_inputs, type_overrides: type_overrides,
|
|
37
|
+
seed: seed, strategies: strategies, random: random, mutate_cap: mutate_cap,
|
|
38
|
+
timeout: timeout, command: command, requires: requires, load_paths: load_paths, host: host
|
|
39
|
+
).explore
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def initialize(target:, entry:, user_inputs:, type_overrides:, seed:, strategies:, random:, mutate_cap:, timeout:, command:,
|
|
43
|
+
requires:, load_paths:, host:)
|
|
44
|
+
@host = host
|
|
45
|
+
@path = target
|
|
46
|
+
@load_paths = load_paths.map { |dir| File.expand_path(dir) }
|
|
47
|
+
@requires = requires.map { |name| required_file(name) or raise Error, require_not_found(name) }
|
|
48
|
+
@entry = entry
|
|
49
|
+
@user_inputs = user_inputs
|
|
50
|
+
@type_overrides = type_overrides
|
|
51
|
+
@seed = seed
|
|
52
|
+
@strategies = STRATEGY_ORDER.select { |name| strategies.include?(name) }
|
|
53
|
+
@random = random
|
|
54
|
+
@mutate_cap = mutate_cap
|
|
55
|
+
@timeout = timeout
|
|
56
|
+
@command = command
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def explore
|
|
60
|
+
target = Target.parse(@path, @entry, type_overrides: @type_overrides)
|
|
61
|
+
user = @strategies.include?("inputs") ? @user_inputs : []
|
|
62
|
+
mutated = @strategies.include?("mutate") ? Mutator.new(target, cap: @mutate_cap).inputs : []
|
|
63
|
+
randoms = @strategies.include?("random") ? Sampler.new(target, seed: @seed, count: @random).inputs : []
|
|
64
|
+
|
|
65
|
+
counts = {inputs: 0, mutate: 0, random: 0}
|
|
66
|
+
seen = {}
|
|
67
|
+
plan = []
|
|
68
|
+
enqueue = lambda do |list, origin|
|
|
69
|
+
list.each do |input|
|
|
70
|
+
key = input.canonical
|
|
71
|
+
next if seen[key]
|
|
72
|
+
|
|
73
|
+
seen[key] = true
|
|
74
|
+
plan << [input, origin]
|
|
75
|
+
counts[origin] += 1
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
enqueue.call(user, :inputs)
|
|
79
|
+
enqueue.call(mutated, :mutate)
|
|
80
|
+
enqueue.call(randoms, :random)
|
|
81
|
+
|
|
82
|
+
runner = Runner.new(target, timeout: @timeout, requires: @requires, load_paths: @load_paths, host: @host)
|
|
83
|
+
executions = plan.map do |input, origin|
|
|
84
|
+
execution = runner.execute(input)
|
|
85
|
+
execution.origin = origin
|
|
86
|
+
execution
|
|
87
|
+
end
|
|
88
|
+
build_result(target, runner, executions, counts, user.length - counts[:inputs])
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
# A require that names no file under the working directory is a
|
|
94
|
+
# feature, as Ruby's own -r reads it: found on an -I directory, then on
|
|
95
|
+
# the load path. Nil when it is found nowhere.
|
|
96
|
+
def required_file(name)
|
|
97
|
+
path = File.expand_path(name)
|
|
98
|
+
return path if File.file?(path) || File.file?("#{path}.rb")
|
|
99
|
+
|
|
100
|
+
feature = name.end_with?(".rb") ? name : "#{name}.rb"
|
|
101
|
+
found = @load_paths.map { |dir| File.join(dir, feature) }.find { |file| File.file?(file) }
|
|
102
|
+
found || $LOAD_PATH.resolve_feature_path(name)&.last
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# In the words of the caller: Branchout.explore, the one caller that
|
|
106
|
+
# passes a host, takes requires: and no -I.
|
|
107
|
+
def require_not_found(name)
|
|
108
|
+
if @host
|
|
109
|
+
"requires: #{name.inspect} names no file under the working directory or on the load path"
|
|
110
|
+
else
|
|
111
|
+
"--require #{name} names no file under the working directory, an -I directory, or the load path"
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def build_result(target, runner, executions, counts, repeated_inputs)
|
|
116
|
+
# Coverage lists every branch of the file, taken or not, so the table
|
|
117
|
+
# from loading holds every arm an execution can report.
|
|
118
|
+
arms = runner.template[:branches].flat_map do |branch_key, arm_counts|
|
|
119
|
+
arm_counts.keys.map { |arm_key| make_arm(target, branch_key, arm_key, executions) }
|
|
120
|
+
end
|
|
121
|
+
covered = arms.select(&:covered).sort_by(&:id)
|
|
122
|
+
frontier = arms.reject(&:covered).sort_by(&:id)
|
|
123
|
+
findings = collect_findings(executions)
|
|
124
|
+
pins = collect_pins(target, executions, covered, findings)
|
|
125
|
+
|
|
126
|
+
Result.new(
|
|
127
|
+
command: @command,
|
|
128
|
+
target: target.display_path,
|
|
129
|
+
entry: target.entry,
|
|
130
|
+
seed: @seed,
|
|
131
|
+
strategies: @strategies,
|
|
132
|
+
random: @random,
|
|
133
|
+
mutate_cap: @mutate_cap,
|
|
134
|
+
timeout: @timeout,
|
|
135
|
+
inputs_ran: counts[:inputs],
|
|
136
|
+
mutate_ran: counts[:mutate],
|
|
137
|
+
random_ran: counts[:random],
|
|
138
|
+
executions: executions.length,
|
|
139
|
+
covered: covered,
|
|
140
|
+
frontier: frontier,
|
|
141
|
+
exceptions: findings,
|
|
142
|
+
pins: pins,
|
|
143
|
+
requires: @requires,
|
|
144
|
+
load_paths: @load_paths,
|
|
145
|
+
target_required: runner.target_required,
|
|
146
|
+
private_calls: runner.private_calls,
|
|
147
|
+
const_expression: runner.const_expression,
|
|
148
|
+
repeated_inputs: repeated_inputs,
|
|
149
|
+
not_counted: runner.not_counted,
|
|
150
|
+
operators: target.operators,
|
|
151
|
+
memos: target.memos,
|
|
152
|
+
boundaries: target.boundaries,
|
|
153
|
+
test_class: @host&.test_class,
|
|
154
|
+
spec_type: @host&.spec_type
|
|
155
|
+
)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def make_arm(target, branch_key, arm_key, executions)
|
|
159
|
+
id = format_id(branch_key, arm_key)
|
|
160
|
+
line = arm_key[2]
|
|
161
|
+
column = arm_key[3]
|
|
162
|
+
takers = executions.select { |execution| count_of(execution, branch_key, arm_key).positive? }
|
|
163
|
+
if takers.empty?
|
|
164
|
+
nearest = best_execution(executions.select { |execution| parent_reached?(execution, branch_key) })
|
|
165
|
+
Arm.new(
|
|
166
|
+
id: id,
|
|
167
|
+
loc: "#{target.display_path}:#{line}:#{column}",
|
|
168
|
+
line: line,
|
|
169
|
+
column: column,
|
|
170
|
+
cond: target.condition_text(branch_key[0], branch_key[2], branch_key[3], arm_key[0], line, column),
|
|
171
|
+
sense: sense_for(branch_key[0], arm_key[0]),
|
|
172
|
+
parent: nearest ? "reached" : "not-reached",
|
|
173
|
+
nearest: nearest&.input,
|
|
174
|
+
path: nearest ? path_ids(nearest) : nil,
|
|
175
|
+
covered: false
|
|
176
|
+
)
|
|
177
|
+
else
|
|
178
|
+
pinnable = takers.reject { |execution| execution.varying == :whole }
|
|
179
|
+
witness = best_execution(pinnable.empty? ? takers : pinnable)
|
|
180
|
+
Arm.new(
|
|
181
|
+
id: id,
|
|
182
|
+
loc: "#{target.display_path}:#{line}:#{column}",
|
|
183
|
+
line: line,
|
|
184
|
+
column: column,
|
|
185
|
+
cond: target.condition_text(branch_key[0], branch_key[2], branch_key[3], arm_key[0], line, column),
|
|
186
|
+
sense: sense_for(branch_key[0], arm_key[0]),
|
|
187
|
+
input: witness.input,
|
|
188
|
+
parent: "reached",
|
|
189
|
+
path: path_ids(witness),
|
|
190
|
+
covered: true,
|
|
191
|
+
pinned: !pinnable.empty?
|
|
192
|
+
)
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def collect_findings(executions)
|
|
197
|
+
chosen = {}
|
|
198
|
+
executions.each do |execution|
|
|
199
|
+
info = execution.error
|
|
200
|
+
next unless info
|
|
201
|
+
|
|
202
|
+
key = [info.class_name, info.message, info.loc.to_s, execution.input.canonical]
|
|
203
|
+
chosen[key] ||= execution
|
|
204
|
+
end
|
|
205
|
+
chosen.values.sort_by { |execution|
|
|
206
|
+
info = execution.error
|
|
207
|
+
[info.class_name, info.loc.to_s, info.message, execution.input.canonical]
|
|
208
|
+
}.map { |execution|
|
|
209
|
+
info = execution.error
|
|
210
|
+
Finding.new(
|
|
211
|
+
class_name: info.class_name,
|
|
212
|
+
message: info.message,
|
|
213
|
+
loc: info.loc,
|
|
214
|
+
input: execution.input,
|
|
215
|
+
stable_message: info.stable_message
|
|
216
|
+
)
|
|
217
|
+
}
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def collect_pins(target, executions, covered, findings)
|
|
221
|
+
by_input = executions.to_h { |execution| [execution.input.canonical, execution] }
|
|
222
|
+
keys = executions.select { |execution| execution.origin == :inputs }.map { |execution| execution.input.canonical } |
|
|
223
|
+
covered.map { |arm| arm.input.canonical } | findings.map { |finding| finding.input.canonical }
|
|
224
|
+
listed = {}
|
|
225
|
+
@user_inputs.each_with_index { |input, at| listed[input.canonical] ||= at }
|
|
226
|
+
keys.sort.map do |key|
|
|
227
|
+
execution = by_input[key]
|
|
228
|
+
Pin.new(
|
|
229
|
+
listed_at: (listed[key] if execution.origin == :inputs),
|
|
230
|
+
input: execution.input,
|
|
231
|
+
value: execution.value,
|
|
232
|
+
error: execution.error,
|
|
233
|
+
covers: covers_of(target, execution),
|
|
234
|
+
thawed: execution.thawed,
|
|
235
|
+
varying: execution.varying
|
|
236
|
+
)
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def covers_of(target, execution)
|
|
241
|
+
covers = execution.branches.flat_map do |branch_key, arms|
|
|
242
|
+
arms.filter_map do |arm_key, count|
|
|
243
|
+
next unless count.to_i.positive?
|
|
244
|
+
|
|
245
|
+
line, text = target.covered_arm(branch_key[0], branch_key[2], branch_key[3], arm_key[0], arm_key[2], arm_key[3])
|
|
246
|
+
Cover.new(line: line, column: branch_key[3], text: text)
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
covers.sort_by { |cover| [cover.line, cover.column, cover.text] }
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def best_execution(executions)
|
|
253
|
+
executions.min_by { |execution| [path_ids(execution).length, execution.input.weight, execution.input.canonical] }
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def parent_reached?(execution, branch_key)
|
|
257
|
+
return true if execution.branches[branch_key].values.any? { |count| count.to_i.positive? }
|
|
258
|
+
return false unless branch_key[0] == :while || branch_key[0] == :until
|
|
259
|
+
|
|
260
|
+
line = branch_key[2]
|
|
261
|
+
execution.lines[line - 1].to_i.positive?
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def count_of(execution, branch_key, arm_key)
|
|
265
|
+
execution.branches[branch_key]&.[](arm_key).to_i
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def path_ids(execution)
|
|
269
|
+
execution.path_ids ||= path_with_counts(execution)
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def path_with_counts(execution)
|
|
273
|
+
ids = []
|
|
274
|
+
execution.branches.each do |branch_key, arms|
|
|
275
|
+
arms.each do |arm_key, count|
|
|
276
|
+
next unless count.to_i.positive?
|
|
277
|
+
|
|
278
|
+
id = format_id(branch_key, arm_key)
|
|
279
|
+
id = "#{id}*#{count}" if count.to_i > 1
|
|
280
|
+
ids << id
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
ids.sort
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def format_id(branch_key, arm_key)
|
|
287
|
+
"#{branch_key[0]}@#{branch_key[2]}:#{branch_key[3]}/#{arm_key[0]}@#{arm_key[2]}:#{arm_key[3]}"
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def sense_for(branch_type, arm_type)
|
|
291
|
+
case [branch_type, arm_type]
|
|
292
|
+
when [:if, :then], [:unless, :else] then "cond-true"
|
|
293
|
+
when [:if, :else], [:unless, :then] then "cond-false"
|
|
294
|
+
when [:while, :body], [:until, :body] then "body-entered"
|
|
295
|
+
when [:case, :when], [:case, :in] then "arm-matched"
|
|
296
|
+
when [:case, :else] then "arm-else"
|
|
297
|
+
when [:"&.", :then] then "receiver-present"
|
|
298
|
+
when [:"&.", :else] then "receiver-nil"
|
|
299
|
+
else "taken"
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Branchout
|
|
4
|
+
# The test that runs explore from inside a suite. +test+ is the example or
|
|
5
|
+
# test instance whose helpers, factories, and fixtures the inputs' Ruby
|
|
6
|
+
# reaches. +around+ runs one input and undoes what it wrote. +test_class+
|
|
7
|
+
# is the class an emitted minitest file inherits from, and +spec_type+ the
|
|
8
|
+
# type metadata an emitted RSpec file gives its describe.
|
|
9
|
+
Host = Data.define(:test, :around, :test_class, :spec_type) do
|
|
10
|
+
# Runs the block with the test's lets forgotten, then puts them back.
|
|
11
|
+
# Returns the names of the lets the block read, the names among them
|
|
12
|
+
# that the test had not made before, and the block's result.
|
|
13
|
+
# RSpec keeps a let's value in @__memoized, minitest/spec in @_memoized.
|
|
14
|
+
def lets_read_by
|
|
15
|
+
holder, name = let_values
|
|
16
|
+
return [[], [], yield] unless holder
|
|
17
|
+
|
|
18
|
+
kept = holder.instance_variable_get(name)
|
|
19
|
+
holder.instance_variable_set(name, {})
|
|
20
|
+
result = yield
|
|
21
|
+
read = holder.instance_variable_get(name).keys
|
|
22
|
+
[read, read.reject { |let| kept&.key?(let) }, result]
|
|
23
|
+
ensure
|
|
24
|
+
holder&.instance_variable_set(name, kept)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def let_values
|
|
30
|
+
rspec = test.instance_variable_get(:@__memoized) if test.instance_variable_defined?(:@__memoized)
|
|
31
|
+
return [rspec, :@memoized] if rspec&.instance_variable_defined?(:@memoized)
|
|
32
|
+
|
|
33
|
+
[test, :@_memoized] if defined?(Minitest::Spec) && test.is_a?(Minitest::Spec)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# The helper files a suite loads before its tests, most specific first.
|
|
38
|
+
# An emitted test requires the first one loaded from the working directory.
|
|
39
|
+
HELPERS = %w[rails_helper.rb test_helper.rb spec_helper.rb].freeze
|
|
40
|
+
|
|
41
|
+
# Each input runs in a savepoint that is rolled back, so no input sees the
|
|
42
|
+
# rows another wrote, and the example's own rows are left as they were.
|
|
43
|
+
# It marks the input as rolled back, also when an around calls it, so the
|
|
44
|
+
# Runner knows that a second run of the input starts from the same rows.
|
|
45
|
+
ROLL_BACK = lambda do |&input|
|
|
46
|
+
ActiveRecord::Base.transaction(requires_new: true) do
|
|
47
|
+
Thread.current[:branchout_rolled_back] = true
|
|
48
|
+
input.call
|
|
49
|
+
raise ActiveRecord::Rollback
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
RUN_ONLY = ->(&input) { input.call }
|
|
54
|
+
|
|
55
|
+
module_function
|
|
56
|
+
|
|
57
|
+
# Explores +entry+ ("Loan#approve" or "Room.open!") from inside a test, and
|
|
58
|
+
# returns the report the CLI prints. +within+ is the running test: pass
|
|
59
|
+
# self. +inputs+ is the inputs file's list, or its path. The target is the
|
|
60
|
+
# file that defines the entry, unless +target+ names it.
|
|
61
|
+
def explore(entry, within:, inputs:, target: nil, emit_tests: nil, format: nil, json: false, verbose: false, pin_keys: [],
|
|
62
|
+
requires: nil, around: nil, strategies: %w[inputs], seed: 0, random: 16, mutate_cap: 256, timeout: 1)
|
|
63
|
+
if verbose && json
|
|
64
|
+
raise Error, "verbose: is only valid with the text report; json: true holds every arm and the run settings"
|
|
65
|
+
end
|
|
66
|
+
target ||= defining_file(entry)
|
|
67
|
+
user_inputs, type_overrides = inputs.is_a?(String) ? InputFile.load(inputs) : [parse_inputs(inputs), nil]
|
|
68
|
+
requires = requires.nil? ? [loaded_helper].compact : Array(requires)
|
|
69
|
+
format = Emitters.resolve(format&.to_s, emit_tests) if emit_tests
|
|
70
|
+
if emit_tests && requires.empty?
|
|
71
|
+
raise Error, "the suite loaded no #{HELPERS.join(", ")} from #{Dir.pwd}, so an emitted test would not load the app; " \
|
|
72
|
+
"pass the helper file as requires:"
|
|
73
|
+
end
|
|
74
|
+
host = Host.new(test: within, around: around || (defined?(ActiveRecord::Base) ? ROLL_BACK : RUN_ONLY),
|
|
75
|
+
test_class: within.class.superclass.name, spec_type: spec_type_of(within))
|
|
76
|
+
result = Engine.explore(
|
|
77
|
+
target: target, entry: entry, user_inputs: user_inputs, type_overrides: type_overrides, seed: seed,
|
|
78
|
+
strategies: strategies, random: random, mutate_cap: mutate_cap, timeout: timeout, requires: requires, host: host
|
|
79
|
+
)
|
|
80
|
+
if emit_tests
|
|
81
|
+
warnings = Emitters.write(format, result, Target.parse(target, entry, type_overrides: type_overrides),
|
|
82
|
+
emit_tests, require_by_name: true, pin_keys: pin_keys)
|
|
83
|
+
warnings.each { |line| $stderr.puts line }
|
|
84
|
+
end
|
|
85
|
+
json ? Report.json(result, pin_keys: pin_keys) : Report.text(result, tests: emit_tests, verbose: verbose, verbose_flag: "verbose: true", pin_keys: pin_keys)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def defining_file(entry)
|
|
89
|
+
match = /\A(.+?)([.#])([^.#]+)\z/.match(entry)
|
|
90
|
+
raise Error, "entry #{entry.inspect} is not Const.method or Const#method" unless match
|
|
91
|
+
|
|
92
|
+
const = Object.const_get(match[1])
|
|
93
|
+
method = match[2] == "." ? const.method(match[3]) : const.instance_method(match[3])
|
|
94
|
+
file, = method.source_location
|
|
95
|
+
raise Error, "#{entry} has no Ruby source file; pass target:" unless file && File.file?(file)
|
|
96
|
+
|
|
97
|
+
file
|
|
98
|
+
rescue NameError => e
|
|
99
|
+
raise Error, "cannot resolve #{entry}: #{e.message.lines.first.strip}"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Symbol keys and JSON values come back as an inputs file would give them.
|
|
103
|
+
def parse_inputs(inputs)
|
|
104
|
+
raise Error, "inputs must be a list or an inputs file path" unless inputs.is_a?(Array)
|
|
105
|
+
|
|
106
|
+
JSON.parse(JSON.generate(inputs)).each_with_index.map { |entry, index| InputFile.parse_entry(entry, "inputs[#{index}]") }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def loaded_helper
|
|
110
|
+
root = "#{Dir.pwd}/"
|
|
111
|
+
loaded = $LOADED_FEATURES.select { |path| path.start_with?(root) }
|
|
112
|
+
HELPERS.each do |name|
|
|
113
|
+
found = loaded.find { |path| File.basename(path) == name }
|
|
114
|
+
return found if found
|
|
115
|
+
end
|
|
116
|
+
nil
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def spec_type_of(test)
|
|
120
|
+
test.class.metadata[:type] if test.class.respond_to?(:metadata)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
private_class_method :spec_type_of, :defining_file, :parse_inputs, :loaded_helper
|
|
124
|
+
end
|