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,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