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,215 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Branchout
|
|
4
|
+
# Ruby source that an input passes in place of a JSON value: a record, an
|
|
5
|
+
# exception, or a block, which JSON cannot write. An inputs file spells it
|
|
6
|
+
# {"ruby": "Factories.loan(3)"}. Each call evaluates it again, so every
|
|
7
|
+
# call gets a new object, and an emitted test writes it as it was given.
|
|
8
|
+
Code = Data.define(:source) do
|
|
9
|
+
def self.parse(source)
|
|
10
|
+
raise Error, "Ruby #{source.inspect} does not parse" unless Prism.parse(source).errors.empty?
|
|
11
|
+
|
|
12
|
+
new(-source)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# A block as a call takes it, written with braces so it binds to the
|
|
16
|
+
# call in an emitted assertion as it did in explore.
|
|
17
|
+
def self.block(source)
|
|
18
|
+
body = Prism.parse("m #{source}").value.statements.body
|
|
19
|
+
unless source.start_with?("{") && body.length == 1 && body.first.respond_to?(:block) && body.first.block
|
|
20
|
+
raise Error, "block #{source.inspect} is not a block such as \"{ |item| item.price > 2 }\""
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
new(-source)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def to_s = source
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# A String input that names a path under the emitted test's project. The
|
|
30
|
+
# test writes it from its own directory (+from_test+), so it passes in a
|
|
31
|
+
# copy of the project on another machine. Its text is the path from the
|
|
32
|
+
# project root, which the test's names and descriptions say.
|
|
33
|
+
class ProjectPath < String
|
|
34
|
+
attr_reader :from_test
|
|
35
|
+
|
|
36
|
+
def initialize(from_root, from_test)
|
|
37
|
+
super(from_root)
|
|
38
|
+
@from_test = -from_test
|
|
39
|
+
freeze
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# A returned String or an error message that holds paths under the
|
|
44
|
+
# emitted test's project. The test writes the project root from its own
|
|
45
|
+
# directory (+root_from_test+) between the +pieces+, so it passes in a
|
|
46
|
+
# copy of the project on another machine. Its text says each path from
|
|
47
|
+
# the project root, as a ProjectPath does.
|
|
48
|
+
class ProjectText < String
|
|
49
|
+
attr_reader :pieces, :root_from_test
|
|
50
|
+
|
|
51
|
+
def initialize(pieces, root_from_test)
|
|
52
|
+
super(pieces.join)
|
|
53
|
+
@pieces = pieces.map { |piece| -piece }.freeze
|
|
54
|
+
@root_from_test = -root_from_test
|
|
55
|
+
freeze
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
class Input
|
|
60
|
+
# One call made on the receiver after new and before the entry.
|
|
61
|
+
# +arguments+ is an Input with no receiver and no setup of its own.
|
|
62
|
+
SetupCall = Struct.new(:method_name, :arguments) do
|
|
63
|
+
def to_h
|
|
64
|
+
arguments.to_h.merge("method" => method_name)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# +receiver+ is the Input whose arguments go to new for a Const#method
|
|
69
|
+
# entry, the Code that builds the receiver itself (a factory call that
|
|
70
|
+
# returns a saved record), or nil to call new with none. +setup+ lists
|
|
71
|
+
# the SetupCalls that build the receiver's state. +pin_keys+, when not nil, replaces
|
|
72
|
+
# --pin-keys for this input's test. It is not part of the call, so it is
|
|
73
|
+
# not in to_h. +block+ is the Code of the block the entry gets, or nil.
|
|
74
|
+
# +before+ is the Code that makes what the call reads, such as the rows
|
|
75
|
+
# a class method looks up, run first in the input's savepoint, or nil.
|
|
76
|
+
attr_reader :args, :kwargs, :receiver, :setup, :pin_keys, :block, :before
|
|
77
|
+
|
|
78
|
+
def initialize(args:, kwargs: {}, receiver: nil, setup: [], pin_keys: nil, block: nil, before: nil)
|
|
79
|
+
@args = deep_freeze(normalize(args))
|
|
80
|
+
unless @args.is_a?(Array)
|
|
81
|
+
raise Error, "input args must be an array"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
raw_kwargs = kwargs || {}
|
|
85
|
+
unless raw_kwargs.is_a?(Hash)
|
|
86
|
+
raise Error, "input kwargs must be an object"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
@kwargs = deep_freeze(raw_kwargs.each_with_object({}) { |(key, value), out|
|
|
90
|
+
out[key.to_s] = normalize(value)
|
|
91
|
+
})
|
|
92
|
+
@receiver = receiver
|
|
93
|
+
@setup = setup.map { |call| SetupCall.new(-call.method_name.to_s, call.arguments).freeze }.freeze
|
|
94
|
+
@pin_keys = pin_keys&.map { |key| -key }.freeze
|
|
95
|
+
@block = block
|
|
96
|
+
@before = before
|
|
97
|
+
freeze
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def to_h
|
|
101
|
+
hash = {"args" => args, "kwargs" => kwargs}
|
|
102
|
+
if receiver.is_a?(Code)
|
|
103
|
+
hash["receiver"] = receiver
|
|
104
|
+
elsif receiver
|
|
105
|
+
hash["new"] = receiver.to_h
|
|
106
|
+
end
|
|
107
|
+
hash["setup"] = setup.map(&:to_h) unless setup.empty?
|
|
108
|
+
hash["block"] = block.source if block
|
|
109
|
+
hash["before"] = before if before
|
|
110
|
+
hash
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def canonical
|
|
114
|
+
Canon.json(to_h)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Short form used in the text report. An input with only args prints as
|
|
118
|
+
# the args array so an agent can paste it back into inputs.json. The
|
|
119
|
+
# entry form leaves out empty kwargs, which an inputs file may omit.
|
|
120
|
+
def text
|
|
121
|
+
return Canon.json(args) if kwargs.empty? && receiver.nil? && setup.empty? && block.nil? && before.nil?
|
|
122
|
+
|
|
123
|
+
Canon.json(short_h)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def short_h
|
|
127
|
+
hash = to_h
|
|
128
|
+
hash.delete("kwargs") if kwargs.empty?
|
|
129
|
+
hash["new"] = receiver.short_h if hash.key?("new")
|
|
130
|
+
hash["setup"] = setup.map { |call| call.arguments.short_h.merge("method" => call.method_name) } unless setup.empty?
|
|
131
|
+
hash
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Smaller is a simpler witness: absolute numeric size, then string
|
|
135
|
+
# length, and each setup call counts one.
|
|
136
|
+
def weight
|
|
137
|
+
args.sum { |value| self.class.weight_of(value) } +
|
|
138
|
+
kwargs.values.sum { |value| self.class.weight_of(value) } +
|
|
139
|
+
self.class.weight_of(receiver) +
|
|
140
|
+
setup.sum { |call| 1 + call.arguments.weight } +
|
|
141
|
+
(block ? block.source.length : 0) +
|
|
142
|
+
(before ? before.source.length : 0)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def self.weight_of(value)
|
|
146
|
+
case value
|
|
147
|
+
when Integer then value.abs
|
|
148
|
+
when Float then value.abs
|
|
149
|
+
when String then value.length
|
|
150
|
+
when Code then value.source.length
|
|
151
|
+
when Input then value.weight
|
|
152
|
+
when Array then value.length + value.sum { |item| weight_of(item) }
|
|
153
|
+
when Hash then value.length + value.values.sum { |item| weight_of(item) }
|
|
154
|
+
else 0
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def self.zero(type)
|
|
159
|
+
case type
|
|
160
|
+
when "int" then 0
|
|
161
|
+
when "float" then 0.0
|
|
162
|
+
when "string" then ""
|
|
163
|
+
when "array" then []
|
|
164
|
+
when "hash" then {}
|
|
165
|
+
when "bool" then false
|
|
166
|
+
else
|
|
167
|
+
raise Error, "unknown type #{type}"
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def normalize(value)
|
|
172
|
+
case value
|
|
173
|
+
when nil, true, false, Integer, ProjectPath
|
|
174
|
+
value
|
|
175
|
+
when String, Symbol
|
|
176
|
+
-value.to_s
|
|
177
|
+
when Float
|
|
178
|
+
raise Error, "non-finite float" unless value.finite?
|
|
179
|
+
|
|
180
|
+
value.zero? ? 0.0 : value
|
|
181
|
+
when Code
|
|
182
|
+
value
|
|
183
|
+
when Array
|
|
184
|
+
value.map { |item| normalize(item) }
|
|
185
|
+
when Hash
|
|
186
|
+
return Code.parse(value.values.first) if code?(value)
|
|
187
|
+
|
|
188
|
+
value.each_with_object({}) do |(key, item), out|
|
|
189
|
+
out[key.to_s] = normalize(item)
|
|
190
|
+
end
|
|
191
|
+
else
|
|
192
|
+
raise Error, "unsupported input value #{value.class}"
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# {"ruby": "..."} is Ruby source. A Hash argument with one key "ruby"
|
|
197
|
+
# is written as Ruby too: {"ruby": "{\"ruby\" => \"3.4\"}"}.
|
|
198
|
+
def code?(value)
|
|
199
|
+
value.length == 1 && value.keys.first.to_s == "ruby" && value.values.first.is_a?(String)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def deep_freeze(value)
|
|
203
|
+
case value
|
|
204
|
+
when Array
|
|
205
|
+
value.each { |item| deep_freeze(item) }
|
|
206
|
+
value.freeze
|
|
207
|
+
when Hash
|
|
208
|
+
value.each { |key, item| deep_freeze(item) }
|
|
209
|
+
value.freeze
|
|
210
|
+
else
|
|
211
|
+
value
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Branchout
|
|
4
|
+
module InputFile
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def load(path)
|
|
8
|
+
raise Error, "inputs file not found: #{path}" unless File.file?(path)
|
|
9
|
+
|
|
10
|
+
begin
|
|
11
|
+
data = JSON.parse(File.read(path))
|
|
12
|
+
rescue JSON::ParserError => e
|
|
13
|
+
raise Error, "invalid JSON in #{path}: #{e.message}"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
types = nil
|
|
17
|
+
entries = data
|
|
18
|
+
if data.is_a?(Hash)
|
|
19
|
+
types = data["types"]
|
|
20
|
+
entries = data["inputs"]
|
|
21
|
+
raise Error, "#{path} is missing \"inputs\"" if entries.nil?
|
|
22
|
+
end
|
|
23
|
+
raise Error, "#{path} inputs must be an array" unless entries.is_a?(Array)
|
|
24
|
+
if types && !types.is_a?(Array)
|
|
25
|
+
raise Error, "#{path} types must be an array"
|
|
26
|
+
end
|
|
27
|
+
if types&.any? { |type| !Mutator::TYPES.include?(type) }
|
|
28
|
+
raise Error, "#{path} has an unknown type (#{Mutator::TYPES.join(", ")})"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
inputs = entries.each_with_index.map { |entry, index| parse_entry(entry, "#{path} inputs[#{index}]") }
|
|
32
|
+
[inputs, types]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Keys that say how the entry is called, so args may be left out for none.
|
|
36
|
+
CALL_KEYS = %w[new receiver setup kwargs block before].freeze
|
|
37
|
+
|
|
38
|
+
METHOD_NAME = /\A(?:[A-Za-z_][A-Za-z0-9_]*[?!=]?|<<|>>|\[\]=?|[-+*\/%&|^<>]|\*\*|<=>|==|!=|<=|>=|=~)\z/
|
|
39
|
+
|
|
40
|
+
def parse_entry(entry, label, keys = %w[args kwargs new receiver setup note pin_keys block before])
|
|
41
|
+
case entry
|
|
42
|
+
when Array
|
|
43
|
+
Input.new(args: entry)
|
|
44
|
+
when Hash
|
|
45
|
+
unknown = entry.keys - keys
|
|
46
|
+
raise Error, "#{label} has unknown keys: #{unknown.join(", ")}" unless unknown.empty?
|
|
47
|
+
unless entry.key?("args") || label.end_with?(" new") || CALL_KEYS.any? { |key| entry.key?(key) }
|
|
48
|
+
raise Error, "#{label} is missing args; write \"args\": [] for a call with no arguments"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
receiver = parse_entry(entry["new"], "#{label} new", %w[args kwargs new]) if entry.key?("new")
|
|
52
|
+
raise Error, "#{label} new cannot have its own new" if receiver&.receiver
|
|
53
|
+
if entry.key?("receiver")
|
|
54
|
+
raise Error, "#{label} gives both new and receiver; receiver builds the object itself" if receiver
|
|
55
|
+
|
|
56
|
+
built = entry["receiver"]
|
|
57
|
+
raise Error, "#{label} receiver must be Ruby such as {\"ruby\": \"create(:loan)\"}" unless built.is_a?(Hash) && code?(built)
|
|
58
|
+
|
|
59
|
+
receiver = Code.parse(built["ruby"])
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
setup = parse_setup(entry.fetch("setup", []), "#{label} setup")
|
|
63
|
+
pin_keys = entry["pin_keys"]
|
|
64
|
+
unless pin_keys.nil? || (pin_keys.is_a?(Array) && pin_keys.all? { |key| key.is_a?(String) && !key.empty? })
|
|
65
|
+
raise Error, "#{label} pin_keys must be an array of key names"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
block = Code.block(entry["block"]) if entry.key?("block")
|
|
69
|
+
if entry.key?("before")
|
|
70
|
+
raise Error, "#{label} before must be Ruby such as {\"ruby\": \"create(:part)\"}" unless entry["before"].is_a?(Hash) && code?(entry["before"])
|
|
71
|
+
|
|
72
|
+
before = Code.parse(entry["before"]["ruby"])
|
|
73
|
+
end
|
|
74
|
+
Input.new(args: entry.fetch("args", []), kwargs: entry["kwargs"] || {}, receiver: receiver, setup: setup, pin_keys: pin_keys, block: block,
|
|
75
|
+
before: before)
|
|
76
|
+
else
|
|
77
|
+
raise Error, "#{label} is #{JSON.generate(entry)}, not a call; write each call as an array of its arguments, such as #{JSON.generate([entry])}"
|
|
78
|
+
end
|
|
79
|
+
rescue Error => e
|
|
80
|
+
raise if e.message.start_with?(label)
|
|
81
|
+
|
|
82
|
+
raise Error, "#{label} #{e.message}"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# A String argument wrapped in its own quotes, as "\"0306406150\"", is
|
|
86
|
+
# most often Ruby or JSON source written where a JSON value goes, and
|
|
87
|
+
# the method then gets the quotes too.
|
|
88
|
+
def quoted_strings(inputs)
|
|
89
|
+
inputs.each_with_index.flat_map do |input, index|
|
|
90
|
+
named = input.args.each_with_index.map { |value, at| ["args[#{at}]", value] } +
|
|
91
|
+
input.kwargs.map { |key, value| ["kwargs.#{key}", value] }
|
|
92
|
+
named.filter_map do |place, value|
|
|
93
|
+
next unless value.is_a?(String) && value.length >= 2 && value.match?(/\A(["']).*\z/m)
|
|
94
|
+
|
|
95
|
+
unquoted = value[1...-1]
|
|
96
|
+
"inputs[#{index}] #{place} is the String #{value.inspect}, quotes included. " \
|
|
97
|
+
"An inputs file is JSON, so write #{unquoted.inspect} for the #{unquoted.empty? ? "empty String" : "String #{unquoted}"}, " \
|
|
98
|
+
"or {\"ruby\": \"...\"} for Ruby source. Keep the quotes only if the method should get them."
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def code?(value)
|
|
104
|
+
value.keys == ["ruby"] && value["ruby"].is_a?(String)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def parse_setup(steps, label)
|
|
108
|
+
raise Error, "#{label} must be an array" unless steps.is_a?(Array)
|
|
109
|
+
|
|
110
|
+
steps.each_with_index.map do |step, index|
|
|
111
|
+
step_label = "#{label}[#{index}]"
|
|
112
|
+
raise Error, "#{step_label} must be an object" unless step.is_a?(Hash)
|
|
113
|
+
|
|
114
|
+
unknown = step.keys - %w[method args kwargs]
|
|
115
|
+
raise Error, "#{step_label} has unknown keys: #{unknown.join(", ")}" unless unknown.empty?
|
|
116
|
+
raise Error, "#{step_label} is missing method" unless step.key?("method")
|
|
117
|
+
|
|
118
|
+
name = step["method"]
|
|
119
|
+
raise Error, "#{step_label} method #{name.inspect} is not a method name" unless name.is_a?(String) && METHOD_NAME.match?(name)
|
|
120
|
+
|
|
121
|
+
Input::SetupCall.new(name, Input.new(args: step.fetch("args", []), kwargs: step["kwargs"] || {}))
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Branchout
|
|
4
|
+
# Literal mutation in the style of a cheap comparison-guided fuzzer.
|
|
5
|
+
# Every source literal is tried in each argument slot. A capped cartesian
|
|
6
|
+
# product of the first two numeric slots comes after that, so a magic
|
|
7
|
+
# constant is not dropped just because it sorts late.
|
|
8
|
+
class Mutator
|
|
9
|
+
TYPES = %w[int float string array hash bool].freeze
|
|
10
|
+
|
|
11
|
+
def initialize(target, cap:)
|
|
12
|
+
@target = target
|
|
13
|
+
@cap = cap
|
|
14
|
+
raise Error, "mutate-cap must be >= 0" if cap.negative?
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# A slot is one value an input passes: a positional or required keyword
|
|
18
|
+
# parameter of the entry, or of initialize when the entry is an instance
|
|
19
|
+
# method. Optional keywords keep their defaults.
|
|
20
|
+
Slot = Struct.new(:owner, :param)
|
|
21
|
+
|
|
22
|
+
def inputs
|
|
23
|
+
return [] if @cap.zero?
|
|
24
|
+
|
|
25
|
+
slots = slots_of(:entry, @target.parameters) + slots_of(:new, @target.constructor)
|
|
26
|
+
base = slots.to_h { |slot| [slot, base_value(slot.param)] }
|
|
27
|
+
pools = slots.to_h { |slot| [slot, pool_for(slot.param)] }
|
|
28
|
+
|
|
29
|
+
tier_one = [build(base)]
|
|
30
|
+
slots.each do |slot|
|
|
31
|
+
pools[slot].each { |value| tier_one << build(base.merge(slot => value)) }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
tier_two = []
|
|
35
|
+
first, second = slots.select { |slot| slot.owner == :entry && slot.param.kind != :keyreq }
|
|
36
|
+
if second && numeric?(first.param) && numeric?(second.param)
|
|
37
|
+
pools[first].each do |left|
|
|
38
|
+
pools[second].each { |right| tier_two << build(base.merge(first => left, second => right)) }
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
deduped = []
|
|
43
|
+
seen = {}
|
|
44
|
+
(sort_inputs(tier_one) + sort_inputs(tier_two)).each do |input|
|
|
45
|
+
key = input.canonical
|
|
46
|
+
next if seen[key]
|
|
47
|
+
|
|
48
|
+
seen[key] = true
|
|
49
|
+
deduped << input
|
|
50
|
+
break if deduped.length >= @cap
|
|
51
|
+
end
|
|
52
|
+
deduped
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Every input passes each required keyword, or the call stops at
|
|
56
|
+
# ArgumentError before the method body runs.
|
|
57
|
+
def slots_of(owner, parameters)
|
|
58
|
+
parameters.select { |param| %i[req opt keyreq].include?(param.kind) }.map { |param| Slot.new(owner, param) }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def build(values)
|
|
62
|
+
entry = values.select { |slot, _| slot.owner == :entry }
|
|
63
|
+
made = values.reject { |slot, _| slot.owner == :entry }
|
|
64
|
+
receiver = arguments(made) unless @target.constructor.empty?
|
|
65
|
+
arguments(entry, receiver)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def arguments(values, receiver = nil)
|
|
69
|
+
args = values.reject { |slot, _| slot.param.kind == :keyreq }.values
|
|
70
|
+
kwargs = values.select { |slot, _| slot.param.kind == :keyreq }.to_h { |slot, value| [slot.param.name.to_s, value] }
|
|
71
|
+
Input.new(args: args, kwargs: kwargs, receiver: receiver)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def pool_for(param)
|
|
75
|
+
literals = @target.literals
|
|
76
|
+
case param.inferred_type
|
|
77
|
+
when "int"
|
|
78
|
+
values = [0, 1, -1]
|
|
79
|
+
values.concat(literals.ints)
|
|
80
|
+
values.concat(literals.ints.flat_map { |number| [number - 1, number + 1] })
|
|
81
|
+
values << param.default if param.default.is_a?(Integer)
|
|
82
|
+
values.grep(Integer).uniq.sort
|
|
83
|
+
when "float"
|
|
84
|
+
values = [0.0, 1.0, -1.0]
|
|
85
|
+
values.concat(literals.floats)
|
|
86
|
+
values.concat(literals.ints.map(&:to_f))
|
|
87
|
+
values.concat(values.flat_map { |number| [number - 1.0, number + 1.0] })
|
|
88
|
+
values << param.default if param.default.is_a?(Float)
|
|
89
|
+
values.grep(Float).select(&:finite?).map { |number| number.zero? ? 0.0 : number }.uniq.sort
|
|
90
|
+
when "string"
|
|
91
|
+
values = ["", "a"]
|
|
92
|
+
values.concat(literals.strings)
|
|
93
|
+
values << param.default if param.default.is_a?(String)
|
|
94
|
+
literals.strings.each do |text|
|
|
95
|
+
values << text[0..-2].to_s
|
|
96
|
+
values << "#{text}1"
|
|
97
|
+
end
|
|
98
|
+
values.uniq.sort
|
|
99
|
+
when "array"
|
|
100
|
+
[[], [0], [1], [0, 1]]
|
|
101
|
+
when "hash"
|
|
102
|
+
[{}, {"a" => 0}, {"a" => 1}]
|
|
103
|
+
when "bool"
|
|
104
|
+
[false, true]
|
|
105
|
+
else
|
|
106
|
+
[Input.zero(param.inferred_type || "int")]
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def base_value(param)
|
|
111
|
+
return param.default unless param.default.nil?
|
|
112
|
+
|
|
113
|
+
Input.zero(param.inferred_type)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def numeric?(param)
|
|
117
|
+
param.inferred_type == "int" || param.inferred_type == "float"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def sort_inputs(list)
|
|
121
|
+
list.sort_by(&:canonical)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Branchout
|
|
4
|
+
# Outside a bundle the first require of prism activates the newest one,
|
|
5
|
+
# and a target that then requires a gem pinned to an older prism, as
|
|
6
|
+
# minitest 6 is to prism ~> 1.5, fails to activate that gem. The CLI
|
|
7
|
+
# parses the target before it loads it, so it activates the newest prism
|
|
8
|
+
# that the newest installed version of every gem accepts.
|
|
9
|
+
module PrismChoice
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def activate
|
|
13
|
+
return if defined?(::Bundler) || Gem.loaded_specs.key?("prism")
|
|
14
|
+
|
|
15
|
+
version = accepted(Gem::Specification.find_all_by_name("prism").map(&:version), Gem::Specification.to_a)
|
|
16
|
+
gem "prism", "= #{version}" if version
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def accepted(versions, specs)
|
|
20
|
+
newest = specs.group_by(&:name).map { |_, same| same.max_by(&:version) }
|
|
21
|
+
wanted = newest.flat_map { |spec| spec.runtime_dependencies.select { |dep| dep.name == "prism" }.map(&:requirement) }
|
|
22
|
+
versions.select { |version| wanted.all? { |requirement| requirement.satisfied_by?(version) } }.max
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|