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