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,449 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Branchout
|
|
4
|
+
module Report
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
# What a sense asks of an input that reaches the decision.
|
|
8
|
+
WANTS = {
|
|
9
|
+
"cond-true" => "makes `%s` true",
|
|
10
|
+
"cond-false" => "makes `%s` false",
|
|
11
|
+
"arm-matched" => "matches `%s`",
|
|
12
|
+
"arm-else" => "matches no branch of `%s`",
|
|
13
|
+
"body-entered" => "enters the loop at `%s`",
|
|
14
|
+
"receiver-present" => "gives `%s` a receiver that is not nil",
|
|
15
|
+
"receiver-nil" => "gives `%s` a nil receiver"
|
|
16
|
+
}.freeze
|
|
17
|
+
SHOWN_INPUTS = 3
|
|
18
|
+
|
|
19
|
+
# The default leads with what to act on: the counts, then each arm an
|
|
20
|
+
# input reached but did not take. The run settings and the covered arms
|
|
21
|
+
# are in +verbose+ and in the JSON.
|
|
22
|
+
def text(result, tests: nil, verbose: false, verbose_flag: "--verbose", pin_keys: [])
|
|
23
|
+
return full_text(result, pin_keys) if verbose
|
|
24
|
+
|
|
25
|
+
total = result.covered.length + result.frontier.length
|
|
26
|
+
raised = result.exceptions.length
|
|
27
|
+
lines = [
|
|
28
|
+
"branchout #{result.command} #{result.target} #{result.entry}",
|
|
29
|
+
"covered: #{result.covered.length} of #{total} arms by #{count(result.executions, "input")}",
|
|
30
|
+
"raised: #{raised.zero? ? "none" : count(raised, "input")}"
|
|
31
|
+
]
|
|
32
|
+
lines << "tests: #{Target.display_path(tests)}" if tests
|
|
33
|
+
frontier_brief(lines, result.frontier)
|
|
34
|
+
returned_brief(lines, returned(Array(result.pins), pin_keys))
|
|
35
|
+
exceptions_brief(lines, result.exceptions)
|
|
36
|
+
not_pinned_brief(lines, result.covered.reject(&:pinned))
|
|
37
|
+
pinned_by_class_brief(lines, result.pins, pin_keys) if tests
|
|
38
|
+
not_counted_brief(lines, Array(result.not_counted))
|
|
39
|
+
operators_brief(lines, Array(result.operators))
|
|
40
|
+
boundaries_brief(lines, Array(result.boundaries))
|
|
41
|
+
lines << "" << "Pass #{verbose_flag} for every covered arm and the run settings."
|
|
42
|
+
"#{lines.join("\n")}\n"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def frontier_brief(lines, frontier)
|
|
46
|
+
reached, waiting = frontier.partition { |arm| arm.parent == "reached" }
|
|
47
|
+
lines << "" << (reached.any? ? "FRONTIER (add an input like the nearest above each arm, changed as the arm says)" : "FRONTIER")
|
|
48
|
+
lines << "(none: every arm ran, which does not mean each value is right; check RETURNED)" if frontier.empty?
|
|
49
|
+
# Consecutive arms share one nearest line, so a first run's single
|
|
50
|
+
# input is printed once, not once for each arm it reached.
|
|
51
|
+
nearest = ->(arm) { arm.nearest ? arm.nearest.text : "null" }
|
|
52
|
+
reached.sort_by { |arm| [arm.line, arm.column, arm.id] }.chunk_while { |a, b| nearest.(a) == nearest.(b) }.each do |arms|
|
|
53
|
+
lines << "nearest: #{nearest.(arms.first)}"
|
|
54
|
+
arms.each { |arm| lines << "#{arm.loc} #{WANTS.fetch(arm.sense, "takes the arm at `%s`") % arm.cond}" }
|
|
55
|
+
end
|
|
56
|
+
return if waiting.empty?
|
|
57
|
+
|
|
58
|
+
lines << "" if reached.any?
|
|
59
|
+
lines << (reached.any? ? "not reached yet; take the arms above first:" : "no input reached these decisions:")
|
|
60
|
+
# An id is "kind@line:column/arm@line:column"; its first half names the decision.
|
|
61
|
+
decisions = waiting.group_by { |arm| arm.id.split("/").first.match(/\A(.+)@(\d+):(\d+)\z/).captures }
|
|
62
|
+
decisions.sort_by { |(_, line, column), _| [line.to_i, column.to_i] }.each do |(kind, line, _), arms|
|
|
63
|
+
file = arms.first.loc.sub(/:\d+:\d+\z/, "")
|
|
64
|
+
conds = arms.map(&:cond).uniq
|
|
65
|
+
lines << "#{file}:#{line} #{conds.one? ? conds.first : kind} (#{count(arms.length, "arm")})"
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def count(number, noun)
|
|
70
|
+
"#{number} #{noun}#{"s" unless number == 1}"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Coverage counts the arms an input took, never what it returned, so a
|
|
74
|
+
# wrong value in code with no branch of its own shows only here. Each
|
|
75
|
+
# input of the inputs file and each arm's input, grouped by what it
|
|
76
|
+
# returned, as [shown, value, pins]: +value+ is the Ruby literal of the
|
|
77
|
+
# whole value, or nil for one with no literal, and +shown+ fits a line.
|
|
78
|
+
def returned(pins, pin_keys)
|
|
79
|
+
groups = pins.reject(&:error).group_by do |pin|
|
|
80
|
+
next [:shown, "a value that differed between two runs"] if pin.varying == :whole
|
|
81
|
+
|
|
82
|
+
pinned = Emitters::Support.pinned_pin(pin, pin_keys)
|
|
83
|
+
next [:value, pinned.value] if Emitters::Support.literal?(pinned.value)
|
|
84
|
+
|
|
85
|
+
shown = shown_hash(pinned.value, []) if Emitters::Support.keyed?(pinned.value)
|
|
86
|
+
[:shown, shown || Emitters::Support.outcome_text(pinned, []).delete_prefix("returns ")]
|
|
87
|
+
end
|
|
88
|
+
values = groups.keys.select { |kind, _| kind == :value }.map(&:last)
|
|
89
|
+
differences = values.zip(Emitters::Support.hash_differences(values)).to_h
|
|
90
|
+
groups.map do |(kind, value), group|
|
|
91
|
+
next [value, nil, group] if kind == :shown
|
|
92
|
+
|
|
93
|
+
whole = Emitters::Support.ruby_literal(Emitters::Support.described(value))
|
|
94
|
+
next [whole, whole, group] if whole.length <= SHOWN_WIDTH
|
|
95
|
+
|
|
96
|
+
shown = shown_hash(value, differences.fetch(value)) if Emitters::Support.keyed?(value)
|
|
97
|
+
[shown || "#{whole[0, SHOWN_WIDTH - 3]}...", whole, group]
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
SHOWN_WIDTH = 72
|
|
102
|
+
SHOWN_INPUT_WIDTH = 50
|
|
103
|
+
|
|
104
|
+
# A Hash too long for a line shows the leaves that tell it from the
|
|
105
|
+
# other values first, since a Hash with the same keys starts the same
|
|
106
|
+
# way, then the rest of its top-level scalars that fit, or nil when
|
|
107
|
+
# none fits. A part with no literal shows its class.
|
|
108
|
+
def shown_hash(value, differing)
|
|
109
|
+
leaf_text = ->(item) { Emitters::Support.literal?(item) ? Emitters::Support.ruby_literal(Emitters::Support.described(item)) : Emitters::Support.article(Emitters::Support.class_literal(item)) }
|
|
110
|
+
text = lambda do |entries|
|
|
111
|
+
omitted = entries.count { |path, _| path.one? } < value.size
|
|
112
|
+
"a Hash with #{Emitters::Support.entries_text(entries.map { |path, item| [path, leaf_text.(item)] })}#{", ..." if omitted}"
|
|
113
|
+
end
|
|
114
|
+
told = differing.map(&:first)
|
|
115
|
+
scalars = Emitters::Support.hash_leaves(value).select { |path, _| path.one? && !told.include?(path) }
|
|
116
|
+
entries = scalars.reduce(differing) { |kept, entry| text.(kept + [entry]).length <= SHOWN_WIDTH ? kept + [entry] : kept }
|
|
117
|
+
return if entries.empty?
|
|
118
|
+
|
|
119
|
+
line = text.(entries)
|
|
120
|
+
line.length <= SHOWN_WIDTH ? line : "#{line[0, SHOWN_WIDTH - 3]}..."
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# An input from the inputs file too long for a line is named by its
|
|
124
|
+
# place there; --json and --verbose keep the whole input.
|
|
125
|
+
def shown_input(pin)
|
|
126
|
+
text = pin.input.text
|
|
127
|
+
text.length > SHOWN_INPUT_WIDTH && pin.listed_at ? "inputs[#{pin.listed_at}]" : text
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def returned_brief(lines, groups)
|
|
131
|
+
return if groups.empty?
|
|
132
|
+
|
|
133
|
+
lines << "" << "RETURNED (coverage does not check a value; compare each with what the method should return)"
|
|
134
|
+
groups.each { |shown, _, group| lines << "returns #{shown} for #{group.map { |pin| shown_input(pin) }.join(", ")}" }
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def exceptions_brief(lines, findings)
|
|
138
|
+
return if findings.empty?
|
|
139
|
+
|
|
140
|
+
lines << "" << "EXCEPTIONS"
|
|
141
|
+
# A message often quotes its own input, so one raise grouped by
|
|
142
|
+
# message would print once for each input that reached it.
|
|
143
|
+
findings.group_by { |finding| [finding.class_name, finding.loc] }.each do |(name, loc), group|
|
|
144
|
+
first = group.first
|
|
145
|
+
by = group.one? ? first.input.text : "#{count(group.length, "input")}, such as #{first.input.text}"
|
|
146
|
+
lines << "#{name}#{" at #{loc}" if loc} raised by #{by}: #{first.message}"
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def not_pinned_brief(lines, arms)
|
|
151
|
+
return if arms.empty?
|
|
152
|
+
|
|
153
|
+
lines << "" << "NOT PINNED (each input that took these arms returned a value that differed between two runs, so no emitted test checks them)"
|
|
154
|
+
arms.group_by { |arm| arm.input.text }.each do |input, group|
|
|
155
|
+
lines << "input: #{input}"
|
|
156
|
+
group.each { |arm| lines << "#{arm.loc} #{WANTS.fetch(arm.sense, "takes the arm at `%s`") % arm.cond}" }
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Each part of a returned value that an emitted test can check only by
|
|
161
|
+
# its class, as "value[:at]" => [class name, inputs that returned it].
|
|
162
|
+
def pinned_by_class(pins, pin_keys)
|
|
163
|
+
parts = pins.reject { |pin| pin.error || pin.varying == :whole }.flat_map do |pin|
|
|
164
|
+
Emitters::Support.class_pins(Emitters::Support.pinned_value(pin.value, Emitters::Support.keys_for(pin, pin_keys))).map { |part| [part, pin.input] }
|
|
165
|
+
end
|
|
166
|
+
parts.group_by(&:first).map do |(path, name), group|
|
|
167
|
+
["value#{path}", name, group.map(&:last).uniq(&:canonical)]
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def pinned_by_class_brief(lines, pins, pin_keys)
|
|
172
|
+
parts = pinned_by_class(pins, pin_keys)
|
|
173
|
+
return if parts.empty?
|
|
174
|
+
|
|
175
|
+
lines << "" << "PINNED BY CLASS (the emitted tests check only the class of these returned values, which have no Ruby literal; " \
|
|
176
|
+
"assert what matters in each by hand)"
|
|
177
|
+
parts.each do |path, name, inputs|
|
|
178
|
+
more = inputs.length - SHOWN_INPUTS
|
|
179
|
+
lines << "#{path} is #{Emitters::Support.article(name)}: #{inputs.first(SHOWN_INPUTS).map(&:text).join(", ")}#{" and #{more} more" if more.positive?}"
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def not_counted_brief(lines, callees)
|
|
184
|
+
return if callees.empty?
|
|
185
|
+
|
|
186
|
+
lines << "" << "NOT COUNTED (arms in other files; explore each as its own entry)"
|
|
187
|
+
callees.each do |callee|
|
|
188
|
+
lines << "#{callee.name} at #{callee.loc} (#{callee.arms.nil? ? "arms unknown" : count(callee.arms, "arm")})"
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def operators_brief(lines, operators)
|
|
193
|
+
return if operators.empty?
|
|
194
|
+
|
|
195
|
+
lines << "" << "OPERATORS (coverage does not see which side ran; vary each left side)"
|
|
196
|
+
operators.each { |found| lines << "#{found.loc} #{found.op} in `#{found.text}`" }
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def boundaries_brief(lines, boundaries)
|
|
200
|
+
return if boundaries.empty?
|
|
201
|
+
|
|
202
|
+
lines << "" << "BOUNDARIES (each comparison in the counted code, listed whether or not an input made both sides equal; coverage does not see which did)"
|
|
203
|
+
boundaries.each { |found| lines << "#{found.loc} #{found.op} in `#{found.text}`" }
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def full_text(result, pin_keys = [])
|
|
207
|
+
lines = header_lines(result)
|
|
208
|
+
section(lines, "COVERED", result.covered) { |arm| covered_lines(arm) }
|
|
209
|
+
section(lines, "FRONTIER", result.frontier) { |arm| [*frontier_lines(arm), "path: #{format_path(arm.path)}"] }
|
|
210
|
+
section(lines, "RETURNED", returned(Array(result.pins), pin_keys)) { |shown, whole, group| ["returns: #{whole || shown}", *group.map { |pin| "input: #{pin.input.text}" }] }
|
|
211
|
+
section(lines, "EXCEPTIONS", result.exceptions) { |finding| finding_lines(finding) }
|
|
212
|
+
section(lines, "NOT COUNTED", Array(result.not_counted)) { |callee| callee_lines(callee) }
|
|
213
|
+
section(lines, "OPERATORS", Array(result.operators)) { |found| operator_lines(found) }
|
|
214
|
+
section(lines, "BOUNDARIES", Array(result.boundaries)) { |found| operator_lines(found) }
|
|
215
|
+
"#{lines.join("\n")}\n"
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def json(result, pin_keys: [])
|
|
219
|
+
body = {
|
|
220
|
+
"version" => 1,
|
|
221
|
+
"command" => result.command,
|
|
222
|
+
"target" => result.target,
|
|
223
|
+
"entry" => result.entry,
|
|
224
|
+
"seed" => result.seed,
|
|
225
|
+
"strategies" => result.strategies,
|
|
226
|
+
"random" => result.random,
|
|
227
|
+
"mutate_cap" => result.mutate_cap,
|
|
228
|
+
"timeout" => result.timeout,
|
|
229
|
+
"inputs_ran" => result.inputs_ran,
|
|
230
|
+
"mutate_ran" => result.mutate_ran,
|
|
231
|
+
"random_ran" => result.random_ran,
|
|
232
|
+
"executions" => result.executions,
|
|
233
|
+
"arms_covered" => result.covered.length,
|
|
234
|
+
"arms_total" => result.covered.length + result.frontier.length,
|
|
235
|
+
"covered" => result.covered.map { |arm| covered_hash(arm) },
|
|
236
|
+
"arms_not_pinned" => result.covered.reject(&:pinned).map { |arm| covered_hash(arm) },
|
|
237
|
+
"frontier" => result.frontier.map { |arm| frontier_hash(arm) },
|
|
238
|
+
"returned" => returned(Array(result.pins), pin_keys).map { |shown, whole, group|
|
|
239
|
+
{"returns" => shown, "value" => whole, "inputs" => group.map { |pin| pin.input.to_h }}.compact
|
|
240
|
+
},
|
|
241
|
+
"exceptions" => result.exceptions.map { |finding| finding_hash(finding) },
|
|
242
|
+
"not_counted" => Array(result.not_counted).map { |callee| callee_hash(callee) },
|
|
243
|
+
"operators_not_counted" => Array(result.operators).map { |found| operator_hash(found) },
|
|
244
|
+
"boundaries_not_counted" => Array(result.boundaries).map { |found| operator_hash(found) },
|
|
245
|
+
"pinned_by_class" => pinned_by_class(Array(result.pins), pin_keys).map do |path, name, inputs|
|
|
246
|
+
{"path" => path, "class" => name, "inputs" => inputs.map(&:to_h)}
|
|
247
|
+
end
|
|
248
|
+
}
|
|
249
|
+
"#{Canon.pretty(body)}\n"
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def markdown(result)
|
|
253
|
+
"# branchout #{result.command}\n\n#{full_text(result)}"
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def baselines_text(info, rows)
|
|
257
|
+
lines = [
|
|
258
|
+
"branchout baselines",
|
|
259
|
+
"target: #{info[:target]}",
|
|
260
|
+
"entry: #{info[:entry]}",
|
|
261
|
+
"seed: #{info[:seed]}",
|
|
262
|
+
"random: #{info[:random]}",
|
|
263
|
+
"mutate_cap: #{info[:mutate_cap]}",
|
|
264
|
+
"timeout: #{format_timeout(info[:timeout])}",
|
|
265
|
+
format("%-10s %11s %8s %6s %11s", "strategy", "executions", "covered", "total", "exceptions")
|
|
266
|
+
]
|
|
267
|
+
rows.each do |name, result|
|
|
268
|
+
total = result.covered.length + result.frontier.length
|
|
269
|
+
lines << format(
|
|
270
|
+
"%-10s %11d %8d %6d %11d",
|
|
271
|
+
name, result.executions, result.covered.length, total, result.exceptions.length
|
|
272
|
+
)
|
|
273
|
+
end
|
|
274
|
+
lines << ""
|
|
275
|
+
rows.each do |name, result|
|
|
276
|
+
ids = result.covered.map(&:id)
|
|
277
|
+
lines << "#{name}_covered: #{ids.empty? ? "(none)" : ids.join(", ")}"
|
|
278
|
+
classes = result.exceptions.map(&:class_name)
|
|
279
|
+
lines << "#{name}_exceptions: #{classes.empty? ? "(none)" : classes.join(", ")}"
|
|
280
|
+
end
|
|
281
|
+
"#{lines.join("\n")}\n"
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def baselines_json(info, rows)
|
|
285
|
+
body = {
|
|
286
|
+
"version" => 1,
|
|
287
|
+
"command" => "baselines",
|
|
288
|
+
"target" => info[:target],
|
|
289
|
+
"entry" => info[:entry],
|
|
290
|
+
"seed" => info[:seed],
|
|
291
|
+
"random" => info[:random],
|
|
292
|
+
"mutate_cap" => info[:mutate_cap],
|
|
293
|
+
"timeout" => info[:timeout],
|
|
294
|
+
"rows" => rows.map { |name, result|
|
|
295
|
+
{
|
|
296
|
+
"strategy" => name,
|
|
297
|
+
"executions" => result.executions,
|
|
298
|
+
"covered" => result.covered.length,
|
|
299
|
+
"total" => result.covered.length + result.frontier.length,
|
|
300
|
+
"exceptions" => result.exceptions.length,
|
|
301
|
+
"covered_ids" => result.covered.map(&:id),
|
|
302
|
+
"exception_classes" => result.exceptions.map(&:class_name)
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
"#{Canon.pretty(body)}\n"
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def baselines_markdown(info, rows)
|
|
310
|
+
"# branchout baselines\n\n#{baselines_text(info, rows)}"
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# A setting of a strategy that did not run is left out, so it does not
|
|
314
|
+
# read as active. The JSON report holds every setting.
|
|
315
|
+
def header_lines(result)
|
|
316
|
+
ran = ->(strategy) { result.strategies.include?(strategy) }
|
|
317
|
+
[
|
|
318
|
+
"branchout #{result.command}",
|
|
319
|
+
"target: #{result.target}",
|
|
320
|
+
"entry: #{result.entry}",
|
|
321
|
+
*("seed: #{result.seed}" if ran.("random")),
|
|
322
|
+
"strategies: #{result.strategies.join(",")}",
|
|
323
|
+
*("random: #{result.random}" if ran.("random")),
|
|
324
|
+
*("mutate_cap: #{result.mutate_cap}" if ran.("mutate")),
|
|
325
|
+
"timeout: #{format_timeout(result.timeout)}",
|
|
326
|
+
*("inputs_ran: #{result.inputs_ran}" if ran.("inputs")),
|
|
327
|
+
*("mutate_ran: #{result.mutate_ran}" if ran.("mutate")),
|
|
328
|
+
*("random_ran: #{result.random_ran}" if ran.("random")),
|
|
329
|
+
"executions: #{result.executions}",
|
|
330
|
+
"arms_covered: #{result.covered.length}",
|
|
331
|
+
"arms_total: #{result.covered.length + result.frontier.length}",
|
|
332
|
+
"exceptions: #{result.exceptions.length}",
|
|
333
|
+
"callees_not_counted: #{Array(result.not_counted).length}",
|
|
334
|
+
"operators_not_counted: #{Array(result.operators).length}",
|
|
335
|
+
"boundaries_not_counted: #{Array(result.boundaries).length}"
|
|
336
|
+
]
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def section(lines, title, records)
|
|
340
|
+
lines << ""
|
|
341
|
+
lines << title
|
|
342
|
+
if records.empty?
|
|
343
|
+
lines << "(none)"
|
|
344
|
+
return
|
|
345
|
+
end
|
|
346
|
+
records.each_with_index do |record, index|
|
|
347
|
+
lines << "" if index.positive?
|
|
348
|
+
lines.concat(yield(record))
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def covered_lines(arm)
|
|
353
|
+
[
|
|
354
|
+
"id: #{arm.id}",
|
|
355
|
+
"loc: #{arm.loc}",
|
|
356
|
+
"cond: #{arm.cond}",
|
|
357
|
+
"sense: #{arm.sense}",
|
|
358
|
+
"input: #{arm.input.text}",
|
|
359
|
+
*("pinned: no, the input differed between two runs" unless arm.pinned)
|
|
360
|
+
]
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def frontier_lines(arm)
|
|
364
|
+
[
|
|
365
|
+
"id: #{arm.id}",
|
|
366
|
+
"loc: #{arm.loc}",
|
|
367
|
+
"cond: #{arm.cond}",
|
|
368
|
+
"sense: #{arm.sense}",
|
|
369
|
+
"parent: #{arm.parent}",
|
|
370
|
+
"nearest: #{arm.nearest ? arm.nearest.text : "null"}"
|
|
371
|
+
]
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def finding_lines(finding)
|
|
375
|
+
[
|
|
376
|
+
"class: #{finding.class_name}",
|
|
377
|
+
"loc: #{finding.loc || "null"}",
|
|
378
|
+
"input: #{finding.input.text}",
|
|
379
|
+
"message: #{finding.message}"
|
|
380
|
+
]
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
def callee_lines(callee)
|
|
384
|
+
["callee: #{callee.name}", "loc: #{callee.loc}", "arms: #{callee.arms.nil? ? "unknown" : callee.arms}"]
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
def operator_lines(found)
|
|
388
|
+
["loc: #{found.loc}", "op: #{found.op}", "text: #{found.text}"]
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
def operator_hash(found)
|
|
392
|
+
{"loc" => found.loc, "line" => found.line, "column" => found.column, "op" => found.op, "text" => found.text}
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
def callee_hash(callee)
|
|
396
|
+
{"callee" => callee.name, "loc" => callee.loc, "arms" => callee.arms}
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
def format_path(path)
|
|
400
|
+
return "null" if path.nil?
|
|
401
|
+
return "(none)" if path.empty?
|
|
402
|
+
|
|
403
|
+
path.join(", ")
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
def covered_hash(arm)
|
|
407
|
+
{
|
|
408
|
+
"id" => arm.id,
|
|
409
|
+
"loc" => arm.loc,
|
|
410
|
+
"line" => arm.line,
|
|
411
|
+
"column" => arm.column,
|
|
412
|
+
"cond" => arm.cond,
|
|
413
|
+
"sense" => arm.sense,
|
|
414
|
+
"input" => arm.input.to_h
|
|
415
|
+
}
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
def frontier_hash(arm)
|
|
419
|
+
{
|
|
420
|
+
"id" => arm.id,
|
|
421
|
+
"loc" => arm.loc,
|
|
422
|
+
"line" => arm.line,
|
|
423
|
+
"column" => arm.column,
|
|
424
|
+
"cond" => arm.cond,
|
|
425
|
+
"sense" => arm.sense,
|
|
426
|
+
"parent" => arm.parent,
|
|
427
|
+
"nearest" => arm.nearest&.to_h,
|
|
428
|
+
"path" => arm.path
|
|
429
|
+
}
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
def finding_hash(finding)
|
|
433
|
+
{
|
|
434
|
+
"class" => finding.class_name,
|
|
435
|
+
"loc" => finding.loc,
|
|
436
|
+
"input" => finding.input.to_h,
|
|
437
|
+
"message" => finding.message
|
|
438
|
+
}
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
def format_timeout(value)
|
|
442
|
+
return "none" if value.nil?
|
|
443
|
+
|
|
444
|
+
return value.to_i.to_s if value == value.to_i
|
|
445
|
+
|
|
446
|
+
Canon.format_float(value.to_f)
|
|
447
|
+
end
|
|
448
|
+
end
|
|
449
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Branchout
|
|
4
|
+
# Park-Miller minimal standard generator. Pure Ruby so the stream does not
|
|
5
|
+
# depend on CRuby's Random implementation.
|
|
6
|
+
class Rng
|
|
7
|
+
MOD = 2_147_483_647
|
|
8
|
+
MULTIPLIER = 16_807
|
|
9
|
+
|
|
10
|
+
def initialize(seed)
|
|
11
|
+
value = Integer(seed)
|
|
12
|
+
raise Error, "seed must be >= 0" if value.negative?
|
|
13
|
+
|
|
14
|
+
@state = value % MOD
|
|
15
|
+
@state = 1 if @state.zero?
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def next_int
|
|
19
|
+
@state = (MULTIPLIER * @state) % MOD
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def rand_inclusive(min, max)
|
|
23
|
+
min = Integer(min)
|
|
24
|
+
max = Integer(max)
|
|
25
|
+
span = max - min + 1
|
|
26
|
+
raise Error, "empty range #{min}..#{max}" if span <= 0
|
|
27
|
+
|
|
28
|
+
min + (next_int % span)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|