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