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,1248 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+ require "pathname"
6
+ require "tmpdir"
7
+
8
+ module Branchout
9
+ module Emitters
10
+ # Shared, deterministic rendering used by every characterization emitter.
11
+ module Support
12
+ module_function
13
+
14
+ def header(target, kind, destination)
15
+ [
16
+ "# frozen_string_literal: true",
17
+ "",
18
+ "# Generated by branchout #{VERSION}. Characterization #{kind} for discovered inputs.",
19
+ "# target: #{header_path(target, destination)}",
20
+ "# entry: #{target.entry}"
21
+ ]
22
+ end
23
+
24
+ # The target as the report names it when it is under the working
25
+ # directory. Otherwise it is relative to the root of its project (the
26
+ # nearest directory with a Gemfile, a gemspec, or .git), so a committed
27
+ # file does not carry one machine's path.
28
+ def header_path(target, destination)
29
+ absolute = File.expand_path(target.path)
30
+ return target.display_path unless target.display_path == absolute
31
+
32
+ root = project_root(File.dirname(absolute))
33
+ root ? absolute.delete_prefix("#{root}/") : relative_require(destination, absolute)
34
+ end
35
+
36
+ def project_root(dir)
37
+ loop do
38
+ marked = %w[Gemfile .git].any? { |name| File.exist?(File.join(dir, name)) } ||
39
+ !Dir.glob("*.gemspec", base: dir).empty?
40
+ return dir if marked
41
+
42
+ parent = File.dirname(dir)
43
+ return nil if parent == dir
44
+
45
+ dir = parent
46
+ end
47
+ end
48
+
49
+ # The requires of an emitted test: the --require files, then the
50
+ # target. +by_name+ requires each through the load path, named from
51
+ # the first of the -I directories (+load_paths+) and the --require
52
+ # files' directories that holds it, so the test loads whichever
53
+ # checkout the runner's -I names. With neither, the nearest lib
54
+ # directory above the target names it, or the target's own directory,
55
+ # as for a library's entry file. A file under none of them keeps its
56
+ # path, behind a comment that says why. A comment above the requires
57
+ # names, as -I, each directory a require by name needs and the
58
+ # runner does not put on the load path itself (+runner_load_paths+,
59
+ # from +from+, where the runner starts).
60
+ def require_lines(requires, target_path, destination, by_name: false, load_paths: [], runner_load_paths: [], from: Dir.pwd)
61
+ roots = name_roots(requires, target_path, load_paths)
62
+ used = []
63
+ lines = [*requires, *target_path].flat_map do |path|
64
+ absolute = File.expand_path(path)
65
+ root = roots.find { |dir| absolute.start_with?(dir) }
66
+ if by_name && root
67
+ used |= [root]
68
+ next ["require #{absolute.delete_prefix(root).delete_suffix(".rb").inspect}"]
69
+ end
70
+
71
+ relative = relative_require(destination, path)
72
+ note = "# Required by path: no -I or --require directory holds #{relative}." if by_name
73
+ [*note, "require_relative #{relative.inspect}"]
74
+ end
75
+ used -= runner_load_paths.map { |dir| "#{File.expand_path(dir, from)}/" }
76
+ used.empty? ? lines : ["# Load path: #{used.map { |dir| "-I #{shown_directory(dir.chomp("/"), from: from)}" }.join(" ")}", *lines]
77
+ end
78
+
79
+ # A directory as a person passes it to -I: from the working directory
80
+ # when it is under it, else from its project's root, named after it,
81
+ # so a committed test carries no machine's path where it can avoid one.
82
+ def shown_directory(dir, from: Dir.pwd)
83
+ cwd = "#{File.expand_path(from)}/"
84
+ return dir.delete_prefix(cwd) if dir.start_with?(cwd)
85
+
86
+ root = project_root(dir)
87
+ return dir unless root
88
+
89
+ "#{root == dir ? "." : dir.delete_prefix("#{root}/")} (in #{File.basename(root)})"
90
+ end
91
+
92
+ # The files of +requires+ and +target_path+ that --require-by-name
93
+ # cannot name, because no directory in +load_paths+ or of +requires+
94
+ # holds them.
95
+ def unnamed_requires(requires, target_path, load_paths: [])
96
+ roots = require_roots(requires, load_paths)
97
+ return [] if roots.empty?
98
+
99
+ [*requires, *target_path].reject { |path| roots.any? { |dir| File.expand_path(path).start_with?(dir) } }
100
+ end
101
+
102
+ # The files require_lines requires by path that sit in another project
103
+ # than +destination+, or under no project when it has one: their
104
+ # require_relative names this machine's directories, absolute or
105
+ # climbing out of the test's project.
106
+ def required_outside_project(requires, target_path, destination, by_name: false, load_paths: [])
107
+ roots = by_name ? name_roots(requires, target_path, load_paths) : []
108
+ home = project_root(File.dirname(File.expand_path(destination)))
109
+ [*requires, *target_path].map { |path| File.expand_path(path) }.select do |absolute|
110
+ roots.none? { |dir| absolute.start_with?(dir) } &&
111
+ (project_root(File.dirname(absolute)) != home || File.absolute_path?(relative_require(destination, absolute)))
112
+ end
113
+ end
114
+
115
+ def name_roots(requires, target_path, load_paths)
116
+ roots = require_roots(requires, load_paths)
117
+ roots.empty? && target_path ? [load_root(target_path)] : roots
118
+ end
119
+
120
+ def require_roots(requires, load_paths)
121
+ [*load_paths, *requires.map { |path| File.dirname(path) }].map { |dir| "#{File.expand_path(dir)}/" }
122
+ end
123
+
124
+ def load_root(path)
125
+ dir = File.dirname(File.expand_path(path))
126
+ lib = dir.split("/").rindex("lib")
127
+ "#{lib ? dir.split("/")[0..lib].join("/") : dir}/"
128
+ end
129
+
130
+ # Paths that share only / give the absolute path: a climb to / breaks
131
+ # as soon as either file moves, and require_relative accepts it.
132
+ def relative_require(destination, target_path)
133
+ from = Pathname.new(File.expand_path(File.dirname(destination)))
134
+ to = Pathname.new(File.expand_path(target_path))
135
+ return to.to_s if from.each_filename.first != to.each_filename.first
136
+
137
+ relative = to.relative_path_from(from).to_s.tr("\\", "/")
138
+ relative.start_with?(".") ? relative : "./#{relative}"
139
+ end
140
+
141
+ # +input+ with each String that names a path under the project of
142
+ # +destination+ as a ProjectPath. A String that File.expand_path
143
+ # would not give back as written, such as one with a trailing /, stays.
144
+ def project_paths(input, destination)
145
+ root = project_root(File.dirname(File.expand_path(destination)))
146
+ return input unless root
147
+
148
+ rewrite = lambda do |value|
149
+ case value
150
+ when ProjectPath then value
151
+ when String then project_path(value, destination, root) || value
152
+ when Array then value.map(&rewrite)
153
+ when Hash then value.transform_values(&rewrite)
154
+ else value
155
+ end
156
+ end
157
+ receiver = input.receiver.is_a?(Input) ? project_paths(input.receiver, destination) : input.receiver
158
+ setup = input.setup.map { |call| Input::SetupCall.new(call.method_name, project_paths(call.arguments, destination)) }
159
+ Input.new(args: rewrite.(input.args), kwargs: rewrite.(input.kwargs), receiver: receiver, setup: setup,
160
+ pin_keys: input.pin_keys, block: input.block, before: input.before)
161
+ end
162
+
163
+ # +value+, a returned value or an error message, with each String
164
+ # that holds a path under the project of +destination+ written from
165
+ # the test's directory: a String that is one such path as a
166
+ # ProjectPath, and one that holds such paths among other text as a
167
+ # ProjectText.
168
+ def project_value(value, destination)
169
+ root = project_root(File.dirname(File.expand_path(destination)))
170
+ return value unless root
171
+
172
+ rewrite = lambda do |item|
173
+ case item
174
+ when ProjectPath, ProjectText then item
175
+ when String
176
+ next item unless item.include?("#{root}/")
177
+
178
+ project_path(item, destination, root) ||
179
+ ProjectText.new(item.split("#{root}/", -1), from_test(destination, root))
180
+ when Array then item.map(&rewrite)
181
+ when Hash then item.transform_values(&rewrite)
182
+ else item
183
+ end
184
+ end
185
+ rewrite.(value)
186
+ end
187
+
188
+ # +value+ as a ProjectPath when it is one path under +root+ that
189
+ # File.expand_path gives back as written (a trailing / it would not).
190
+ def project_path(value, destination, root)
191
+ return nil unless value.start_with?("#{root}/")
192
+
193
+ relative = from_test(destination, value)
194
+ File.expand_path(relative, File.dirname(File.expand_path(destination))) == value ? ProjectPath.new(value.delete_prefix("#{root}/"), relative) : nil
195
+ end
196
+
197
+ # File.expand_path reads a leading ~ as a home directory.
198
+ def from_test(destination, path)
199
+ relative_require(destination, path).sub(%r{\A\./(?!~)}, "")
200
+ end
201
+
202
+ # The paths of this machine, under the home or the temporary
203
+ # directory, that the Strings of +value+ hold anywhere in their text.
204
+ def machine_paths_in(value)
205
+ homes = machine_homes.map { |home| Regexp.escape(home) }.join("|")
206
+ strings(value).flat_map { |text| text.scan(%r{(?:#{homes})[^\s"'`(),;]+}) }
207
+ end
208
+
209
+ def machine_homes
210
+ [Dir.tmpdir, Dir.home].flat_map { |dir| ["#{dir}/", "#{File.realpath(dir)}/"] }.uniq
211
+ end
212
+
213
+ # The Strings of +input+ that are a path of this machine: one under
214
+ # the home or the temporary directory, whether it exists or not.
215
+ # /dev/null, or /tmp itself, is the same on every machine.
216
+ def machine_paths(input)
217
+ strings(input).select do |value|
218
+ File.absolute_path?(value) && machine_homes.any? { |home| value.start_with?(home) }
219
+ end
220
+ end
221
+
222
+ def strings(value)
223
+ case value
224
+ when ProjectPath, ProjectText then []
225
+ when String then [value]
226
+ when Array then value.flat_map { |item| strings(item) }
227
+ when Hash then strings(value.values)
228
+ when Input
229
+ [value.args, value.kwargs, value.receiver, *value.setup.map(&:arguments)].flat_map { |item| strings(item) }
230
+ else []
231
+ end
232
+ end
233
+
234
+ # The statements that build the receiver, and the entry call. With no
235
+ # setup calls the receiver is built inline and there is no statement.
236
+ # A name in +private_calls+ (the entry, or new) is called through send,
237
+ # as explore called it. +const+ is the class as the test writes it.
238
+ def arrange(target, input, private_calls: [], const: target.const_name, thawed: false)
239
+ receiver = if target.singleton
240
+ const
241
+ else
242
+ receiver_text(input, const, private_calls, thawed: thawed)
243
+ end
244
+ call = ->(on) { with_block(call_text(on, target.method_name, argument_list(input, thawed: thawed), private_calls), input.block) }
245
+ before = input.before ? input.before.source.lines(chomp: true) : []
246
+ return [before, call.(receiver)] if input.setup.empty? && !input.receiver.is_a?(Code)
247
+
248
+ name = input.receiver.is_a?(Code) ? built_receiver_name(target) : receiver_name(target)
249
+ statements = [*before, "#{name} = #{receiver}"]
250
+ input.setup.each do |setup_call|
251
+ statements << call_text(name, setup_call.method_name, argument_list(setup_call.arguments, thawed: thawed), [])
252
+ end
253
+ [statements, call.(name)]
254
+ end
255
+
256
+ # How the receiver of an instance method's input is built: the Code
257
+ # the input gives, or new with the input's arguments for it.
258
+ def receiver_text(input, const, private_calls, thawed: false)
259
+ return input.receiver.source if input.receiver.is_a?(Code)
260
+
261
+ call_text(const, :new, input.receiver ? argument_list(input.receiver, thawed: thawed) : "", private_calls)
262
+ end
263
+
264
+ def call_text(on, method_name, arguments, private_calls)
265
+ # A setter or an operator goes through send: "cart.limit=" and
266
+ # "cart.limit=(a: 1)" do not parse, and "cart.limit=()" passes nil.
267
+ plain = method_name.to_s.match?(/\A[A-Za-z_][A-Za-z0-9_]*[?!]?\z/)
268
+ if plain && !private_calls.include?(method_name)
269
+ return arguments.empty? ? "#{on}.#{method_name}" : "#{on}.#{method_name}(#{arguments})"
270
+ end
271
+
272
+ "#{on}.send(#{[method_name.to_sym.inspect, *(arguments unless arguments.empty?)].join(", ")})"
273
+ end
274
+
275
+ def with_block(call, block)
276
+ block ? "#{call} #{block.source}" : call
277
+ end
278
+
279
+ # The name a receiver built by the input's Ruby takes in every format,
280
+ # which RSpec's own method names cannot take, so that the input's
281
+ # arguments can name it while explore runs and in the emitted test.
282
+ def built_receiver_name(target)
283
+ name = receiver_name(target)
284
+ Emitters::RSpec::TAKEN_NAMES.include?(name) ? "object" : name
285
+ end
286
+
287
+ # The class name in snake case, as a reader would name the object.
288
+ def receiver_name(target)
289
+ name = target.const_name.split("::").last
290
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
291
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
292
+ .downcase
293
+ RESERVED_NAMES.include?(name) ? "subject" : name
294
+ end
295
+
296
+ RESERVED_NAMES = %w[
297
+ alias and begin break case class def defined do else elsif end ensure false for if in
298
+ module next nil not or redo rescue retry return self super then true undef unless until
299
+ when while yield error
300
+ ].freeze
301
+
302
+ def argument_list(input, thawed: false)
303
+ args = input.args.map { |value| ruby_literal(value, thawed: thawed) }
304
+ kwargs = input.kwargs.keys.map { |key|
305
+ "#{keyword_literal(key)}: #{ruby_literal(input.kwargs[key], thawed: thawed)}"
306
+ }
307
+ (args + kwargs).join(", ")
308
+ end
309
+
310
+ # An emitted test has frozen_string_literal, so a +thawed+ string is
311
+ # written with a leading + to match the copy the explore run passed.
312
+ def ruby_literal(value, thawed: false)
313
+ case value
314
+ when nil then "nil"
315
+ when true then "true"
316
+ when false then "false"
317
+ when Integer then value.to_s
318
+ when Float then Canon.format_float(value)
319
+ when ProjectPath then "File.expand_path(#{value.from_test.inspect}, __dir__)"
320
+ when ProjectText
321
+ root = "\#{File.expand_path(#{value.root_from_test.inspect}, __dir__)}/"
322
+ %("#{value.pieces.map { |piece| piece.inspect[1...-1] }.join(root)}")
323
+ when String then thawed ? "+#{value.inspect}" : value.inspect
324
+ when Symbol then value.inspect
325
+ when Code then value.source
326
+ when Struct, Data
327
+ members = value.to_h.map { |key, item| positional?(value) ? ruby_literal(item, thawed: thawed) : "#{keyword_literal(key)}: #{ruby_literal(item, thawed: thawed)}" }
328
+ "#{value.class.name}.new(#{members.join(", ")})"
329
+ when Set then "Set[#{value.map { |item| ruby_literal(item, thawed: thawed) }.join(", ")}]"
330
+ when Array
331
+ "[#{value.map { |item| ruby_literal(item, thawed: thawed) }.join(", ")}]"
332
+ when Hash
333
+ # Insertion order, as the value prints: a failure diff then lines
334
+ # up key by key, and Hash equality ignores order anyway.
335
+ value.empty? ? "{}" : "{ #{value.map { |key, item| "#{hash_key_literal(key)} #{ruby_literal(item, thawed: thawed)}" }.join(", ")} }"
336
+ else
337
+ return "Date.new(#{value.year}, #{value.mon}, #{value.mday})" if plain_date?(value)
338
+
339
+ raise Error, "cannot emit a literal for #{value.class}"
340
+ end
341
+ end
342
+
343
+ # A Regexp literal for a String that quotes a backtrace frame, whose
344
+ # label Ruby 3.4 writes in another form than 3.3, or nil.
345
+ def frame_pattern(value)
346
+ source = value.is_a?(String) && Runner::ExceptionInfo.frame_pattern(value)
347
+ Regexp.new(source).inspect if source
348
+ end
349
+
350
+ def literal?(value)
351
+ case value
352
+ when nil, true, false, Integer, String, Symbol then true
353
+ when Float then value.finite?
354
+ when Array, Set then value.all? { |item| literal?(item) }
355
+ when Hash then value.all? { |key, item| literal?(key) && literal?(item) }
356
+ when Code then false
357
+ when Struct, Data then value_object?(value)
358
+ else plain_date?(value)
359
+ end
360
+ end
361
+
362
+ # A Date of the default calendar reads back from Date.new(year, month,
363
+ # day). A DateTime, or a Date of another calendar reform, does not.
364
+ def plain_date?(value)
365
+ defined?(::Date) && value.instance_of?(::Date) && value.start == ::Date::ITALY
366
+ end
367
+
368
+ # A Data or a Struct reads back from its class's new when its class's
369
+ # name leads to it, each member has a literal, and new with those
370
+ # members makes an equal value: an initialize of its own may change
371
+ # what it is given.
372
+ def value_object?(value)
373
+ name = value.class.name
374
+ return false unless name && Object.const_get(name).equal?(value.class)
375
+ return false unless value.to_h.each_value.all? { |item| literal?(item) }
376
+
377
+ positional?(value) ? value.class.new(*value.to_a) == value : value.class.new(**value.to_h) == value
378
+ rescue StandardError
379
+ false
380
+ end
381
+
382
+ # A Struct made with keyword_init: false takes its members by position.
383
+ def positional?(value)
384
+ value.is_a?(Struct) && value.class.keyword_init? == false
385
+ end
386
+
387
+ # A value with no Ruby literal, such as a Set or a Struct, is pinned by
388
+ # its class. An anonymous class has no stable name, so its nearest
389
+ # named superclass stands in.
390
+ def class_literal(value)
391
+ value.class.ancestors.find { |mod| mod.is_a?(Class) && mod.name }.name
392
+ end
393
+
394
+ # A Symbol key takes the key: form, as the keyword of a call does.
395
+ def hash_key_literal(key)
396
+ key.is_a?(Symbol) ? "#{keyword_literal(key)}:" : "#{ruby_literal(key)} =>"
397
+ end
398
+
399
+ def keyword_literal(key)
400
+ text = key.to_s
401
+ return text if text.match?(/\A[A-Za-z_][A-Za-z0-9_]*[?!]?\z/)
402
+
403
+ text.inspect
404
+ end
405
+
406
+ def class_name_for(target)
407
+ const_part = target.const_name.gsub("::", "")
408
+ method_part = target.method_name.to_s.sub(/[?!=]\z/, "?" => "_predicate", "!" => "_bang", "=" => "_writer")
409
+ method_part = method_part.split("_").map { |word| word.sub(/\A[a-z]/, &:upcase) }.join
410
+ "#{const_part}#{method_part}CharacterizationTest"
411
+ end
412
+
413
+ # Minitest method names: the input's values, then what the test pins.
414
+ # Siblings, inputs whose names would be the same, are named by the
415
+ # leaves that differ between them instead, so a name tells them apart
416
+ # and adding an input can rename its siblings. A name that still
417
+ # collides gets six hex digits of its input's digest, and a digest
418
+ # collision a counter.
419
+ def test_names(pins, pin_keys = [])
420
+ pinned = pins.map { |pin| pinned_pin(pin, pin_keys) }
421
+ differences = hash_differences(pinned.map { |pin| pin.value unless pin.error }, pins.map(&:input))
422
+ # A Hash outcome may take more room, so the leaves RSpec names fit, and
423
+ # so may an Array of scalars and collections, so its keys fit after
424
+ # "array".
425
+ outcomes = pinned.zip(differences).map do |pin, differing|
426
+ roomy = !differing.empty? || (!pin.error && mixed_array?(pin.value))
427
+ clip(outcome_words(pin, differing), roomy ? OUTCOME_LIMIT : 30)
428
+ end
429
+ names = pins.each_with_index.map { |pin, index| name_of(input_words(pin.input), outcomes[index]) }
430
+ names.each_index.group_by { |index| names[index] }.each_value do |siblings|
431
+ next if siblings.one?
432
+
433
+ words = differing_words(siblings.map { |index| pins[index].input })
434
+ siblings.zip(words) { |index, differing| names[index] = name_of(differing, outcomes[index]) }
435
+ end
436
+ repeated = names.tally
437
+ names = names.each_with_index.map do |name, index|
438
+ repeated[name] > 1 ? "#{name}_#{Digest::SHA256.hexdigest(pins[index].input.canonical)[0, 6]}" : name
439
+ end
440
+ seen = Hash.new(0)
441
+ names.map { |name| (seen[name] += 1) == 1 ? name : "#{name}_#{seen[name]}" }
442
+ end
443
+
444
+ def name_of(input_words, outcome)
445
+ ["test", clip(input_words, 40), outcome].reject(&:empty?).join("_")
446
+ end
447
+
448
+ # For each input, the words of every leaf whose value is not the same
449
+ # in all +inputs+: the deepest key above it, then its value, or "none"
450
+ # where the input has no such leaf. Strings at one leaf drop the words
451
+ # they all start with, so a shared start does not fill the name.
452
+ # Leaves keep their order in the first input by canonical form, so the
453
+ # words do not depend on the order of +inputs+.
454
+ def differing_words(inputs)
455
+ leaves = inputs.map { |input| input_leaves(input).to_h }
456
+ paths = inputs.zip(leaves).sort_by { |input, _| input.canonical }.flat_map { |_, map| map.keys }.uniq
457
+ differing = paths.reject do |path|
458
+ leaves.all? { |map| map.key?(path) && map[path].eql?(leaves.first[path]) }
459
+ end
460
+ shared = differing.to_h { |path| [path, shared_start(leaves.select { |map| map.key?(path) }.map { |map| map[path] })] }
461
+ leaves.map do |map|
462
+ differing.flat_map do |path|
463
+ # An input that has an empty collection above this leaf, or
464
+ # items below it, does have the key; its own leaf names it.
465
+ next [] if !map.key?(path) && map.keys.any? { |other| other.first(path.length) == path || path.first(other.length) == other }
466
+ # So does one that holds another shape under the leaf's last key.
467
+ # A Symbol key and a String key of one name are the one key a
468
+ # name can say.
469
+ held = path.rindex { |step| key_step?(step) }
470
+ above = held && path.first(held + 1).map { |step| plain_step(step) }
471
+ next [] if !map.key?(path) && held && map.keys.any? { |other| other.first(held + 1).map { |step| plain_step(step) } == above }
472
+ # An item of an Array has no key to say "none" after, so a Hash
473
+ # that the input holds in that Array's place names it.
474
+ parent = path[0...-1]
475
+ next [] if !map.key?(path) && path.last.is_a?(Integer) &&
476
+ map.keys.any? { |other| other.first(parent.length) == parent && key_step?(other[parent.length]) }
477
+
478
+ key = telling_key(path, paths)
479
+ # Ruby source keeps the word before the part, as the name it
480
+ # compares (limit_100), where a String drops its whole shared start.
481
+ kept = map[path].is_a?(Code) ? [shared[path] - 1, 0].max : shared[path]
482
+ rest = worded?(map[path]) ? tokens(map[path]).drop(kept) : []
483
+ value = if !map.key?(path) then ["none"]
484
+ elsif map[path] == {} || map[path] == [] then ["empty"]
485
+ elsif !rest.empty? then rest
486
+ else value_words(map[path])
487
+ end
488
+ key ? [key, *value] : value
489
+ end.then { |words| without_repeated_none(words) }
490
+ end
491
+ end
492
+
493
+ # A key that several leaves of an absent collection end in says
494
+ # "none" once, and so do the missing items at the end of a shorter
495
+ # Array, which have no key.
496
+ def without_repeated_none(words)
497
+ out = []
498
+ index = 0
499
+ while index < words.length
500
+ if words[index].is_a?(Key) && words[index + 1] == "none"
501
+ pair = words[index, 2]
502
+ out.concat(pair) unless out.each_cons(2).include?(pair)
503
+ index += 2
504
+ elsif words[index] == "none" && out.last == "none" && !out[-2].is_a?(Key)
505
+ index += 1
506
+ else
507
+ out << words[index]
508
+ index += 1
509
+ end
510
+ end
511
+ out
512
+ end
513
+
514
+ # A String, or the Ruby source of a receiver or argument, reads as words.
515
+ def worded?(value) = value.is_a?(String) || value.is_a?(Code)
516
+
517
+ # How many leading words all of +values+ share, when they read as words.
518
+ def shared_start(values)
519
+ return 0 unless values.all? { |value| worded?(value) }
520
+
521
+ words = values.map { |value| tokens(value) }
522
+ words.first.each_index.take_while { |index| words.all? { |list| list[index] == words.first[index] } }.length
523
+ end
524
+
525
+ # [path, value] for every scalar or empty collection of an input. A
526
+ # Symbol step names a part of the input, a String a String Hash key, a
527
+ # SymbolKey a Symbol Hash key, and an Integer a position.
528
+ def input_leaves(input)
529
+ out = []
530
+ input.args.each_with_index { |arg, index| collect_leaves(arg, [:args, index], out) }
531
+ input.kwargs.each { |key, value| collect_leaves(value, [:kwargs, key], out) }
532
+ if input.receiver.is_a?(Code)
533
+ out << [[:receiver], input.receiver]
534
+ elsif input.receiver
535
+ input_leaves(input.receiver).each { |path, value| out << [[:new, *path], value] }
536
+ end
537
+ input.setup.each_with_index do |call, index|
538
+ out << [[:setup, index, :method], call.method_name]
539
+ input_leaves(call.arguments).each { |path, value| out << [[:setup, index, *path], value] }
540
+ end
541
+ out << [[:block], input.block] if input.block
542
+ out << [[:before], input.before] if input.before
543
+ out
544
+ end
545
+
546
+ # +whole_arrays+ keeps an Array of scalars as one leaf, so a reader
547
+ # sees items is [1, 2] and not each item by its position.
548
+ def collect_leaves(value, path, out, whole_arrays: false)
549
+ return out << [path, value] if path.last.is_a?(TapBlock)
550
+
551
+ value = written_value(value)
552
+ children = case value
553
+ when Code then call_parts(value)
554
+ when Hash then value.map { |key, item| [key.is_a?(Symbol) ? SymbolKey.new(key.to_s) : key.to_s, item] }
555
+ when Array
556
+ whole_arrays && value.none? { |item| collection?(item) } ? [] : value.each_with_index.map { |item, index| [index, item] }
557
+ else []
558
+ end
559
+ return out << [path, value] if children.empty?
560
+
561
+ children.each { |step, item| collect_leaves(item, path + [step], out, whole_arrays: whole_arrays) }
562
+ end
563
+
564
+ # What each pin's test pins, as RSpec says it: "raises KeyError",
565
+ # "returns :miss". A Hash says the scalar entries that differ between
566
+ # the Hashes of +pins+, so siblings say how their results differ.
567
+ # +short+ names a leaf by its deepest key (discarded: 1).
568
+ def outcomes(pins, pin_keys = [], short: false)
569
+ pinned = pins.map { |pin| pinned_pin(pin, pin_keys) }
570
+ differences = hash_differences(pinned.map { |pin| pin.value unless pin.error }, pins.map(&:input))
571
+ pins.each_index.to_h { |index| [pins[index], outcome_text(pinned[index], differences[index], short: short)] }
572
+ end
573
+
574
+ # The entries of a returned Hash whose keys +pin_keys+ names, by
575
+ # name, so a key added later fails no test. A value that is not a
576
+ # Hash, or a Hash with none of the keys, is pinned whole.
577
+ def pinned_value(value, pin_keys)
578
+ return value if pin_keys.empty? || !value.is_a?(Hash)
579
+
580
+ chosen = value.select { |key, _| pin_keys.include?(key.to_s) }
581
+ chosen.empty? ? value : chosen
582
+ end
583
+
584
+ def pinned_pin(pin, pin_keys)
585
+ pin.error ? pin : pin.dup.tap { |copy| copy.value = pinned_value(pin.value, keys_for(pin, pin_keys)) }
586
+ end
587
+
588
+ # The keys that pin +pin+'s Hash: its input's own, or else +pin_keys+,
589
+ # without the keys whose values differed between two runs.
590
+ def keys_for(pin, pin_keys)
591
+ keys = pin.input.pin_keys || pin_keys
592
+ return keys unless pin.varying.is_a?(Array)
593
+
594
+ stable = pin.value.keys.map(&:to_s) - pin.varying
595
+ chosen = keys & stable
596
+ chosen.empty? ? stable : chosen
597
+ end
598
+
599
+ def outcome_text(pin, differing, short: false)
600
+ return "raises #{pin.error.class_name}" if pin.error
601
+
602
+ value = pin.value
603
+ unless differing.empty?
604
+ entries = differing.map do |path, item|
605
+ [short ? telling_path(path, differing.map(&:first)) : path, ruby_literal(described(item))]
606
+ end
607
+ return "returns #{entries_text(entries, merge: !short)}"
608
+ end
609
+ unless literal?(value)
610
+ return "returns an Array of #{value.length}" if value.is_a?(Array)
611
+ return "returns a Hash of #{value.keys.map(&:to_s).join(", ")}" if keyed?(value)
612
+
613
+ return "returns #{article(class_literal(value))}"
614
+ end
615
+
616
+ text = ruby_literal(described(value))
617
+ return "returns #{text}" if text.length <= 40
618
+
619
+ # A String or an Array of scalars says its start, as the minitest
620
+ # name says its first words.
621
+ case value
622
+ when Hash then "returns a Hash of #{value.keys.map(&:to_s).join(", ")}"
623
+ when Array
624
+ if value.all? { |item| composite?(item) } then "returns an Array of #{value.length}"
625
+ elsif value.any? { |item| composite?(item) } then "returns [#{value.map { |item| item_text(item) }.join(", ")}]"
626
+ else "returns #{text[0, 37]}..."
627
+ end
628
+ when String then "returns #{text[0...word_end(text, 0, 37)]}..."
629
+ when Struct, Data then "returns #{article(value.class.name)}"
630
+ else "returns #{text}"
631
+ end
632
+ end
633
+
634
+ # Where a cut of +text+ from +start+ to +finish+ ends: at +finish+,
635
+ # or, when that falls inside a word, before the word, unless that
636
+ # leaves fewer than 12 characters.
637
+ def word_end(text, start, finish)
638
+ return finish unless finish < text.length && text[finish - 1].match?(/\w/) && text[finish].match?(/\w/)
639
+
640
+ boundary = text.rindex(/\W/, finish - 1)
641
+ boundary && boundary - start >= 12 ? boundary : finish
642
+ end
643
+
644
+ # An item of an Array that holds both scalars and collections: a
645
+ # scalar as written, and a collection by its keys or its size.
646
+ def item_text(item)
647
+ case item
648
+ when Hash then item.empty? ? "{}" : "a Hash of #{item.keys.map(&:to_s).join(", ")}"
649
+ when Array then item.empty? ? "[]" : "an Array of #{item.length}"
650
+ when Struct, Data then article(item.class.name)
651
+ else ruby_literal(item)
652
+ end
653
+ end
654
+
655
+ # An RSpec matcher argument for a value with no literal: a Struct or
656
+ # Data by its class and members, an Array or Hash by its items, and
657
+ # anything else by its class.
658
+ def shape_matcher(value)
659
+ case value
660
+ when Struct, Data
661
+ members = value.to_h.map { |key, item| "#{key}: #{literal?(item) ? ruby_literal(item) : shape_matcher(item)}" }
662
+ "a_kind_of(#{class_literal(value)}).and(have_attributes(#{members.join(", ")}))"
663
+ when Array then "[#{value.map { |item| literal?(item) ? ruby_literal(item) : shape_matcher(item) }.join(", ")}]"
664
+ # RSpec finds each expected key in the Hash, so a key with no
665
+ # literal leaves the Hash pinned by its class, as class_pins says.
666
+ when Hash
667
+ return "a_kind_of(Hash)" unless value.each_key.all? { |key| literal?(key) }
668
+
669
+ entries = value.map { |key, item| "#{hash_key_literal(key)} #{literal?(item) ? ruby_literal(item) : shape_matcher(item)}" }
670
+ entries.empty? ? "{}" : "{ #{entries.join(", ")} }"
671
+ else "a_kind_of(#{class_literal(value)})"
672
+ end
673
+ end
674
+
675
+ # Minitest assertions on the returned +value+, held in a local named
676
+ # value, that check what shape_matcher checks: each part with a
677
+ # literal by its value, and each part class_pins names by its class.
678
+ # A Hash or an Array is compared whole, each part with no literal
679
+ # standing in as itself, so a missing key or item fails too.
680
+ def shape_assertions(value, path = "")
681
+ return [literal_assertion(value, "value#{path}")] if literal?(value)
682
+
683
+ case value
684
+ when Hash, Array
685
+ return ["assert_kind_of Hash, value#{path}"] if value.is_a?(Hash) && !value.each_key.all? { |key| literal?(key) }
686
+
687
+ parts = []
688
+ whole = shape_template(value, path, parts)
689
+ [*parts, "assert_equal(#{whole}, value#{path})"]
690
+ when Struct, Data
691
+ members = value.to_h
692
+ literal, other = members.partition { |_, item| literal?(item) }.map(&:to_h)
693
+ lines = ["assert_kind_of #{class_literal(value)}, value#{path}"]
694
+ unless literal.empty?
695
+ keys = literal.keys.map { |key| ruby_literal(key) }.join(", ")
696
+ lines << "assert_equal(#{ruby_literal(literal)}, value#{path}.to_h.slice(#{keys}))"
697
+ end
698
+ lines + other.flat_map { |key, item| shape_assertions(item, "#{path}.#{key}") }
699
+ else ["assert_kind_of #{class_literal(value)}, value#{path}"]
700
+ end
701
+ end
702
+
703
+ # Minitest 6 fails assert_equal with an expected nil.
704
+ def literal_assertion(value, actual)
705
+ return "assert_nil #{actual}" if value.nil?
706
+
707
+ "assert_equal(#{ruby_literal(value)}, #{actual})"
708
+ end
709
+
710
+ # The literal of a Hash or an Array whose parts with no literal are
711
+ # read from the returned value, so comparing it with the value checks
712
+ # the rest; +parts+ gets the assertions on those parts.
713
+ def shape_template(value, path, parts)
714
+ return ruby_literal(value) if literal?(value)
715
+
716
+ case value
717
+ when Array then "[#{value.each_with_index.map { |item, index| shape_template(item, "#{path}[#{index}]", parts) }.join(", ")}]"
718
+ when Hash
719
+ if value.each_key.all? { |key| literal?(key) }
720
+ entries = value.map { |key, item| "#{hash_key_literal(key)} #{shape_template(item, "#{path}[#{ruby_literal(key)}]", parts)}" }
721
+ return entries.empty? ? "{}" : "{ #{entries.join(", ")} }"
722
+ end
723
+ parts.concat(shape_assertions(value, path))
724
+ "value#{path}"
725
+ else
726
+ parts.concat(shape_assertions(value, path))
727
+ "value#{path}"
728
+ end
729
+ end
730
+
731
+ # [path, class name] for each part of +value+ that has no literal, as
732
+ # the RSpec matcher reaches it: into a Hash, an Array, and the members
733
+ # of a Struct or Data, and no further.
734
+ def class_pins(value, path = "")
735
+ return [] if literal?(value)
736
+
737
+ case value
738
+ when Hash
739
+ value.flat_map { |key, item| literal?(key) ? class_pins(item, "#{path}[#{ruby_literal(key)}]") : [[path, class_literal(value)]] }.uniq
740
+ when Array then value.each_with_index.flat_map { |item, index| class_pins(item, "#{path}[#{index}]") }
741
+ when Struct, Data then value.to_h.flat_map { |key, item| class_pins(item, "#{path}.#{key}") }
742
+ else [[path, class_literal(value)]]
743
+ end
744
+ end
745
+
746
+ def article(name)
747
+ name.match?(/\A[AEIOU]/) ? "an #{name}" : "a #{name}"
748
+ end
749
+
750
+ # For each of +values+, the [path, leaf] entries of a Hash at the
751
+ # paths chosen to tell its siblings apart: the fewest leaves, nested
752
+ # ones too, that tell every two different Hashes of +values+ apart, at
753
+ # most three. Each pick is a leaf that does not repeat its input,
754
+ # then a leaf every Hash has, when one tells a pair apart, then tells
755
+ # the most pairs apart, then has the most distinct values. A pick that would make a minitest outcome longer
756
+ # than OUTCOME_LIMIT is left out, so both formats name the same
757
+ # leaves. A value that is not a Hash has none.
758
+ def hash_differences(values, inputs = [])
759
+ # A part with no literal is checked by its class, so it tells no
760
+ # sibling apart.
761
+ leaves = values.map { |value| named_leaves(value).select { |_, (_, leaf)| literal?(leaf) } if keyed?(value) }
762
+ echoes = leaves.each_index.map { |index| inputs[index] ? inputs[index].args.map { |arg| written_value(arg) }.grep(Hash).map { |arg| named_leaves(arg) }.reduce({}, :merge) : {} }
763
+ distinct = leaves.compact.uniq.sort_by(&:inspect)
764
+ paths = distinct.flat_map(&:keys).uniq
765
+ pairs = distinct.combination(2).to_a
766
+ # A leaf every Hash has says something about each of them, so it
767
+ # comes before one that only some have.
768
+ shared = paths.to_h { |path| [path, distinct.all? { |map| map.key?(path) }] }
769
+ # A leaf that holds what the input passed at the same path, such as
770
+ # a document the method merges into, says nothing the input did not.
771
+ echoed = paths.to_h do |path|
772
+ [path, leaves.each_index.all? { |index| !leaves[index]&.key?(path) || (echoes[index].key?(path) && echoes[index][path].eql?(leaves[index][path])) }]
773
+ end
774
+ chosen = []
775
+ entries = ->(map, picks) { picks.filter_map { |path| map[path] if map.key?(path) } }
776
+ until pairs.empty? || chosen.length == 3
777
+ told = paths.to_h { |path| [path, pairs.count { |a, b| leaves_differ?(a, b, path) }] }
778
+ spread = paths.to_h { |path| [path, distinct.map { |map| map.fetch(path, MISSING) }.uniq.length] }
779
+ best = paths.each_with_index.max_by { |path, index| [told[path].positive? && !echoed[path] ? 1 : 0, told[path].positive? && shared[path] ? 1 : 0, told[path], spread[path], -index] }.first
780
+ break if chosen.any? && distinct.any? { |map| outcome_length(entries.(map, chosen + [best])) > OUTCOME_LIMIT }
781
+
782
+ chosen << best
783
+ pairs.reject! { |a, b| leaves_differ?(a, b, best) }
784
+ end
785
+ leaves.map { |map| map ? entries.(map, chosen) : [] }
786
+ end
787
+
788
+ MISSING = Object.new.freeze
789
+ # The longest minitest outcome part of a name, in characters: room for
790
+ # the "and" before each of the second and third leaves too.
791
+ OUTCOME_LIMIT = 51
792
+
793
+ def outcome_length(entries)
794
+ clip(outcome_entry_words(entries), Float::INFINITY).length
795
+ end
796
+
797
+ # path => leaf for every scalar or empty collection under a Hash.
798
+ def hash_leaves(value)
799
+ [].tap { |out| collect_leaves(value, [], out) }.reject { |path, _| path.empty? }.to_h
800
+ end
801
+
802
+ # hash_leaves by the names of their keys, each to its own [path, leaf],
803
+ # so a key that one Hash holds as a Symbol and another as a String is
804
+ # one leaf to compare, and each still reads as its Hash holds it.
805
+ def named_leaves(value)
806
+ hash_leaves(value).to_h { |path, leaf| [path.map { |step| plain_step(step) }, [path, leaf]] }
807
+ end
808
+
809
+ def leaves_differ?(a, b, path)
810
+ !(a.key?(path) && b.key?(path) && a[path].eql?(b[path]))
811
+ end
812
+
813
+ # "returns", then each entry's key, as telling_key names it, and its
814
+ # value.
815
+ def outcome_entry_words(entries)
816
+ words = entries.flat_map do |path, item|
817
+ [telling_key(path, entries.map(&:first)), *(item == [] || item == {} ? ["empty"] : value_words(item))].compact
818
+ end
819
+ ["returns", *words]
820
+ end
821
+
822
+ # A path into a value as a reader writes it. A Symbol key that is a
823
+ # plain name reads bare, lines[0].sku, and any other Symbol key as a
824
+ # Symbol, [:"a b"]. A String key keeps its quotes, params["sku"], so a
825
+ # String-keyed Hash does not read as if a Symbol lookup would find it.
826
+ # +root+, when given, is the name the path starts from.
827
+ def path_text(path, root = nil)
828
+ path.each_with_object(+"#{root}") do |step, text|
829
+ text << if step.is_a?(CallHead) then ""
830
+ elsif step.is_a?(TapBlock) then ".#{step.name}"
831
+ elsif step.is_a?(Integer) then "[#{step}]"
832
+ elsif !step.is_a?(SymbolKey) then "[#{step.inspect}]"
833
+ elsif !step.name.match?(/\A[A-Za-z_][A-Za-z0-9_]*[?!]?\z/) then "[#{step.name.to_sym.inspect}]"
834
+ elsif text.empty? then step.name
835
+ else ".#{step.name}"
836
+ end
837
+ end
838
+ end
839
+
840
+ # Leaves of a returned Hash, each a [path, value text], as a reader
841
+ # writes them: due: 0 and lines[0].sku: "A" by their path, and a leaf
842
+ # whose path would start at a bracket as the literal that holds it,
843
+ # "counts" => {"discarded" => 0}. With +merge+, leaves under one key
844
+ # share its literal, "tests" => [{"file" => "a.rb", "state" => "ok"}];
845
+ # a short path does not, since its first key may sit at two depths.
846
+ def entries_text(entries, merge: true)
847
+ groups = []
848
+ entries.each do |path, text|
849
+ name = path_text(path)
850
+ next groups << "#{name}#{path.last.is_a?(String) ? " =>" : ":"} #{text}" unless name.start_with?("[")
851
+
852
+ group = groups.find { |other| other.is_a?(Array) && other.first.first.first == path.first } if merge
853
+ group ? group << [path, text] : groups << [[path, text]]
854
+ end
855
+ groups.map { |group| group.is_a?(Array) ? held_entries(group, braces: false) : group }.join(", ")
856
+ end
857
+
858
+ # Leaves that share a value, each a [path into it, value text], as
859
+ # the literal of that value, with _ for an Array element no leaf is in.
860
+ def held_entries(entries, braces: true)
861
+ return entries.first[1] if entries.first[0].empty?
862
+
863
+ steps = entries.group_by { |path, _| path.first }.transform_values { |group| group.map { |path, text| [path.drop(1), text] } }
864
+ return "[#{(0..steps.keys.max).map { |index| steps.key?(index) ? held_entries(steps[index]) : "_" }.join(", ")}]" if steps.keys.first.is_a?(Integer)
865
+
866
+ pairs = steps.map { |key, group| "#{key_literal(key)} #{held_entries(group)}" }.join(", ")
867
+ braces ? "{#{pairs}}" : pairs
868
+ end
869
+
870
+ def key_literal(key)
871
+ return "#{key.inspect} =>" unless key.is_a?(SymbolKey)
872
+
873
+ key.name.match?(/\A[A-Za-z_][A-Za-z0-9_]*[?!]?\z/) ? "#{key.name}:" : "#{key.name.to_sym.inspect} =>"
874
+ end
875
+
876
+ # A Symbol Hash key in a path. It is a step of its own, so a String key
877
+ # of the same name makes another path and the two inputs read apart.
878
+ SymbolKey = Data.define(:name) do
879
+ def to_s = name
880
+ end
881
+
882
+ def key_step?(step) = step.is_a?(String) || step.is_a?(SymbolKey)
883
+
884
+ def plain_step(step) = step.is_a?(SymbolKey) ? step.name : step
885
+
886
+ def key_texts(path) = path.select { |step| key_step?(step) }.map(&:to_s)
887
+
888
+ # A Code that is a plain literal, such as [{sku: "B"}], names as the
889
+ # value it builds, so it reads in the style of a JSON value and its
890
+ # Symbol keys read apart from String keys. Any other Code stays whole.
891
+ def written_value(value)
892
+ return value unless value.is_a?(Code)
893
+
894
+ body = Prism.parse(value.source).value.statements&.body || []
895
+ return value unless body.one?
896
+
897
+ catch(:not_plain) { return plain_literal(body.first) }
898
+ value
899
+ end
900
+
901
+ # +lines+ of Ruby, each indented by +indent+, except an empty line and
902
+ # a line of the body or the terminator of a << or <<- heredoc: an
903
+ # indented terminator of << does not end it, and <<- keeps the
904
+ # indentation of its body in its text. A <<~ heredoc removes the
905
+ # indentation its lines share, so its body is indented with the rest.
906
+ def indent(lines, indent)
907
+ kept = heredoc_lines(lines.join("\n"))
908
+ lines.each_with_index.map { |line, index| line.empty? || kept.include?(index + 1) ? line : "#{indent}#{line}" }
909
+ end
910
+
911
+ # Node types, not classes: naming a class would load prism when branchout loads, before the suite chose its version.
912
+ HEREDOCS = %i[string_node interpolated_string_node x_string_node interpolated_x_string_node].freeze
913
+
914
+ def heredoc_lines(source, node = Prism.parse(source).value, out = Set.new)
915
+ if HEREDOCS.include?(node.type) && node.opening_loc&.slice&.match?(/\A<<[-A-Za-z_"'`]/)
916
+ first = node.respond_to?(:content_loc) ? node.content_loc : node.parts.first&.location
917
+ out.merge((first || node.closing_loc).start_line..node.closing_loc.start_line)
918
+ end
919
+ node.compact_child_nodes.each { |child| heredoc_lines(source, child, out) }
920
+ out
921
+ end
922
+
923
+ # The statements of Ruby source, each as its own Code, so sources
924
+ # that share some lines read apart by the lines that differ.
925
+ def statements(code)
926
+ body = Prism.parse(code.source).value.statements&.body || []
927
+ body.length < 2 ? [code] : body.map { |node| Code.new(node.slice) }
928
+ end
929
+
930
+ # The call before its keyword arguments, in a path into a Ruby call.
931
+ # It has no name of its own, so the leaf reads as the argument.
932
+ CallHead = Data.define
933
+
934
+ # A Ruby call with keyword arguments, such as a factory call, as its
935
+ # head and each keyword, so inputs that share a long call read apart
936
+ # by the keywords that differ (b is "Y") and not by a cut source.
937
+ # Empty for any other Ruby, which stays one leaf.
938
+ def call_parts(code)
939
+ body = Prism.parse(code.source).value.statements&.body || []
940
+ call = body.first if body.one?
941
+ return tap_parts(call) if tapped?(call)
942
+ return [] unless call.is_a?(Prism::CallNode) && call.block.nil? && call.arguments
943
+
944
+ *positional, keywords = call.arguments.arguments
945
+ return [] unless keywords.is_a?(Prism::KeywordHashNode) &&
946
+ keywords.elements.all? { |pair| pair.is_a?(Prism::AssocNode) && pair.key.is_a?(Prism::SymbolNode) }
947
+
948
+ head = code.source[0...keywords.location.start_offset - call.location.start_offset].sub(/[(,\s]+\z/, "")
949
+ head = "#{head})" if call.opening_loc && !positional.empty?
950
+ [[CallHead.new, Code.new(head)]] + keywords.elements.map do |pair|
951
+ plain = catch(:not_plain) { [plain_literal(pair.value)] }
952
+ [SymbolKey.new(pair.key.unescaped), plain ? plain.first : Code.new(pair.value.slice)]
953
+ end
954
+ end
955
+
956
+ # The block of a tap or then, as a step of a path. A Ruby argument
957
+ # that runs one, such as FactoryBot.build(:fact).tap { create(:part) },
958
+ # reads apart from its siblings by the block, which the call's source
959
+ # cut at its start would not show.
960
+ TapBlock = Data.define(:name)
961
+
962
+ def tapped?(call)
963
+ call.is_a?(Prism::CallNode) && %i[tap then].include?(call.name) && call.receiver && call.arguments.nil? &&
964
+ call.block.is_a?(Prism::BlockNode)
965
+ end
966
+
967
+ # The parts of the receiver, as call_parts gives them or as one head,
968
+ # then the block on one line.
969
+ def tap_parts(call)
970
+ receiver = Code.new(call.receiver.slice)
971
+ parts = call_parts(receiver)
972
+ parts = [[CallHead.new, receiver]] if parts.empty?
973
+ parts + [[TapBlock.new(call.name.to_s), Code.new(block_text(call.block))]]
974
+ end
975
+
976
+ def block_text(block)
977
+ body = block.body
978
+ statements = body.is_a?(Prism::StatementsNode) ? body.body.map(&:slice) : [body&.slice].compact
979
+ words = [block.opening_loc.slice, block.parameters&.slice, statements.join("; "), block.closing_loc.slice]
980
+ words.compact.reject(&:empty?).join(" ").gsub(/\s*\n\s*/, " ")
981
+ end
982
+
983
+ def plain_literal(node)
984
+ case node
985
+ when Prism::ArrayNode then node.elements.map { |item| plain_literal(item) }
986
+ when Prism::HashNode, Prism::KeywordHashNode
987
+ node.elements.to_h do |pair|
988
+ throw :not_plain unless pair.is_a?(Prism::AssocNode)
989
+
990
+ [plain_literal(pair.key), plain_literal(pair.value)]
991
+ end
992
+ when Prism::StringNode then node.unescaped
993
+ when Prism::SymbolNode then node.unescaped.to_sym
994
+ when Prism::IntegerNode, Prism::FloatNode then node.value
995
+ when Prism::NilNode then nil
996
+ when Prism::TrueNode then true
997
+ when Prism::FalseNode then false
998
+ else throw :not_plain
999
+ end
1000
+ end
1001
+
1002
+ def input_words(input)
1003
+ words = input.args.flat_map { |arg| argument_words(arg) }
1004
+ words += input.kwargs.flat_map { |key, value| [key.to_s, *argument_words(value)] }
1005
+ if input.receiver.is_a?(Code)
1006
+ words += ["receiver", *value_words(input.receiver)]
1007
+ elsif input.receiver
1008
+ words += ["new", *input_words(input.receiver)]
1009
+ end
1010
+ input.setup.each { |call| words += [call.method_name, *input_words(call.arguments)] }
1011
+ words += %w[with block] if input.block
1012
+ words += ["after", *value_words(input.before)] if input.before
1013
+ words
1014
+ end
1015
+
1016
+ # +differing+ is the pin's entry of hash_differences: a Hash whose
1017
+ # siblings differ in a leaf is named by those leaves.
1018
+ def outcome_words(pin, differing = [])
1019
+ return ["raises", *snake_words(pin.error.class_name)] if pin.error
1020
+ return outcome_entry_words(differing) unless differing.empty?
1021
+ return ["returns", *shape_words(pin.value)] if literal?(pin.value) || keyed?(pin.value)
1022
+ return ["returns", "array", "of", pin.value.length.to_s] if pin.value.is_a?(Array)
1023
+
1024
+ ["returns", *snake_words(class_literal(pin.value))]
1025
+ end
1026
+
1027
+ # A Hash whose keys all have a literal, so RSpec matches it key by key
1028
+ # and a name can say its keys.
1029
+ def keyed?(value) = value.is_a?(Hash) && value.each_key.all? { |key| literal?(key) }
1030
+
1031
+ # A Hash is named by its keys and an Array of collections by its size,
1032
+ # so the start of a long value does not fill the name.
1033
+ def shape_words(value)
1034
+ case value
1035
+ when {}, [] then ["empty", value.is_a?(Hash) ? "hash" : "array"]
1036
+ when Hash then ["hash", *value.keys.each_with_index.map { |key, index| Key.new(key.to_s, index.positive?) }]
1037
+ when Array
1038
+ if value.all? { |item| composite?(item) } then ["array", "of", value.length.to_s]
1039
+ # Any other Array says so, so [nil] is not named as nil, [3] not
1040
+ # as 3, and ["Done", []] not as the String "Done".
1041
+ elsif mixed_array?(value) then ["array", *value.flat_map { |item| collection?(item) ? shape_words(item) : value_words(item) }]
1042
+ else ["array", *value_words(value)]
1043
+ end
1044
+ when Struct, Data then snake_words(value.class.name)
1045
+ else value_words(value)
1046
+ end
1047
+ end
1048
+
1049
+ # A Hash argument is named by its values, a collection in it by its
1050
+ # key and size (groups_3, tags_empty), and an Array of collections by
1051
+ # its size, so a tree of arguments does not fill the name and two
1052
+ # trees of different sizes read apart. An empty argument still says
1053
+ # so (empty_array), so the name keeps every input.
1054
+ def argument_words(value)
1055
+ value = written_value(value)
1056
+ case value
1057
+ when {}, [] then shape_words(value)
1058
+ when Hash
1059
+ value.flat_map { |key, item| collection?(item) ? [key.to_s, item.empty? ? "empty" : item.length.to_s] : value_words(item) }
1060
+ when Array
1061
+ value.any? { |item| collection?(item) } ? ["array", "of", value.length.to_s] : value_words(value)
1062
+ else value_words(value)
1063
+ end
1064
+ end
1065
+
1066
+ def collection?(value)
1067
+ value.is_a?(Hash) || value.is_a?(Array)
1068
+ end
1069
+
1070
+ # A value object reads as one thing, as a collection does, and not as
1071
+ # the scalars it holds.
1072
+ def composite?(value)
1073
+ collection?(value) || value.is_a?(Struct) || value.is_a?(Data)
1074
+ end
1075
+
1076
+ def mixed_array?(value)
1077
+ value.is_a?(Array) && value.any? { |item| collection?(item) } && !value.all? { |item| collection?(item) }
1078
+ end
1079
+
1080
+ # The shortest end of +path+ that starts at a Hash key and ends no
1081
+ # other path in +paths+, so id at the top and items[0].id read apart.
1082
+ # With no key in +path+, the whole path.
1083
+ def telling_path(path, paths)
1084
+ starts = path.each_index.select { |index| key_step?(path[index]) }
1085
+ return path if starts.empty?
1086
+
1087
+ others = paths.reject { |other| other == path }
1088
+ found = starts.reverse.find do |start|
1089
+ tail = path.drop(start)
1090
+ others.none? { |other| other.last(tail.length) == tail }
1091
+ end
1092
+ path.drop(found || starts.first)
1093
+ end
1094
+
1095
+ # The fewest last Hash keys of +path+ that end the keys of no other
1096
+ # path in +paths+, as one Key: items_id. Paths with the same keys,
1097
+ # such as args[0].value and args[1].value, cannot be told apart by
1098
+ # keys, so they do not lengthen each other's.
1099
+ def telling_key(path, paths)
1100
+ keys = key_texts(path)
1101
+ return nil if keys.empty?
1102
+
1103
+ others = paths.map { |other| key_texts(other) }.reject { |other| other == keys }
1104
+ length = (1..keys.length).find { |count| others.none? { |other| other.last(count) == keys.last(count) } }
1105
+ Key.new(keys.last(length || keys.length).join(" "))
1106
+ end
1107
+
1108
+ def value_words(value)
1109
+ case value
1110
+ when Array then value.flat_map { |item| value_words(item) }
1111
+ when Set then value.empty? ? %w[empty set] : ["set", *value.flat_map { |item| value_words(item) }]
1112
+ when Integer then [number_word(value)]
1113
+ when nil then ["nil"]
1114
+ when "" then ["empty"]
1115
+ when /\A\s+\z/ then ["blank"]
1116
+ when /\A[^A-Za-z0-9]+\z/ then ["text"]
1117
+ when Numeric then [value.to_s.sub(/\A-/, "neg")]
1118
+ when String then literal_safe([without_machine_directories(value)])
1119
+ when Symbol then literal_safe([value.to_s])
1120
+ else [value.to_s]
1121
+ end
1122
+ end
1123
+
1124
+ # A String or a Symbol that reads as nil, true, or false says it is
1125
+ # text, so a name does not claim the literal for it.
1126
+ def literal_safe(words)
1127
+ %w[nil true false].include?(tokens(words.first).join("_")) ? ["text", *words] : words
1128
+ end
1129
+
1130
+ # +value+ as a name or a description says it: a path under the
1131
+ # project from the project root, and a path of this machine by its
1132
+ # file alone.
1133
+ def described(value)
1134
+ case value
1135
+ when ProjectPath, ProjectText then String.new(value)
1136
+ when String then without_machine_directories(value)
1137
+ when Array then value.map { |item| described(item) }
1138
+ when Hash then value.transform_values { |item| described(item) }
1139
+ else value
1140
+ end
1141
+ end
1142
+
1143
+ # The directories of a path under the home or the temporary directory
1144
+ # differ per machine, so a name keeps only the file of such a path.
1145
+ # A whole path may hold spaces, so it keeps all of its file name.
1146
+ def without_machine_directories(text)
1147
+ return File.basename(text) if machine_homes.any? { |home| text.start_with?(home) } && !text.end_with?("/")
1148
+
1149
+ homes = machine_homes.map { |home| Regexp.escape(home) }.join("|")
1150
+ text.gsub(%r{(?:#{homes})(?:[^\s"\x27`(),;/]+/)*}, "")
1151
+ end
1152
+
1153
+ # A number longer than LONG_NUMBER digits fills a name, so it keeps
1154
+ # its first three digits and its power of ten: 123e46.
1155
+ def number_word(value)
1156
+ digits = value.abs.to_s
1157
+ word = digits.length > LONG_NUMBER ? "#{digits[0, 3]}e#{digits.length - 3}" : digits
1158
+ value.negative? ? "neg#{word}" : word
1159
+ end
1160
+
1161
+ LONG_NUMBER = 15
1162
+
1163
+ def snake_words(class_name)
1164
+ [class_name.gsub("::", "_").gsub(/([a-z\d])([A-Z])/, '\1_\2')]
1165
+ end
1166
+
1167
+ # A Hash key in a name. It is one unit that a cut never splits, and a
1168
+ # key that +follows+ another key of the same list comes after "and",
1169
+ # so line_items reads as one key and not as the keys line and items.
1170
+ # A key that starts a list, such as the first key of each Hash in an
1171
+ # Array, does not follow the keys before it.
1172
+ Key = Data.define(:text, :follows) do
1173
+ def initialize(text:, follows: true) = super
1174
+ end
1175
+
1176
+ # Joins words into identifier characters, at most +limit+ long, cut
1177
+ # between words. Only a first word longer than +limit+ is cut inside.
1178
+ # A key whose value the cut drops goes too, so a name never ends in a
1179
+ # key that says nothing.
1180
+ def clip(words, limit)
1181
+ listed = false
1182
+ units = words.flat_map do |word|
1183
+ next tokens(word).map { |token| ["_", token, false] } unless word.is_a?(Key)
1184
+
1185
+ # A key with no letter or digit, such as "", still says it is a key,
1186
+ # so its value is not read as the whole outcome, and so does a key
1187
+ # that reads as nil, true, or false: {"NIL" => 0} is never
1188
+ # returns_nil_0.
1189
+ key = tokens(word.text).join("_")
1190
+ key = "key" if key.empty?
1191
+ key = "key_#{key}" if %w[nil true false].include?(key)
1192
+
1193
+ joiner = listed && word.follows ? "_and_" : "_"
1194
+ listed = true
1195
+ [[joiner, key, true]]
1196
+ end
1197
+ return "" if units.empty?
1198
+
1199
+ text = units.first[1]
1200
+ return text[0, limit] if text.length > limit
1201
+
1202
+ taken = 1
1203
+ units.drop(1).each do |joiner, unit|
1204
+ break if text.length + joiner.length + unit.length > limit
1205
+
1206
+ text += joiner + unit
1207
+ taken += 1
1208
+ end
1209
+ last, cut = units[taken - 1], units[taken]
1210
+ return text.delete_suffix("#{last[0]}#{last[1]}") if last[2] && cut && !cut[2]
1211
+
1212
+ text
1213
+ end
1214
+
1215
+ def tokens(word)
1216
+ word.to_s.downcase.split(/[^a-z0-9]+/).reject(&:empty?)
1217
+ end
1218
+
1219
+ # Why a file holds no test, in the words of the report's NOT PINNED.
1220
+ def no_pin_reason(all_varied)
1221
+ return "branchout did not discover an input" unless all_varied
1222
+
1223
+ "each input returned a value that differed between two runs, so no emitted test checks it"
1224
+ end
1225
+
1226
+ def cover_comment(pin, indent)
1227
+ return [] if pin.covers.empty?
1228
+
1229
+ ["#{indent}# covers:", *pin.covers.map { |cover| "#{indent}# line #{cover.line}: #{cover.text}" }]
1230
+ end
1231
+
1232
+ # Says why the Strings of a thawed input carry a +, which a reader of
1233
+ # a frozen_string_literal file would otherwise take for noise.
1234
+ def thawed_comment(pin, indent)
1235
+ return [] unless pin.thawed
1236
+
1237
+ ["#{indent}# With frozen Strings this input raised FrozenError, so its Strings are unfrozen (+\"...\").",
1238
+ "#{indent}# This value is from a second run, which saw what the first run changed before it raised and no " \
1239
+ "savepoint undid, such as a global, a class variable, or a file."]
1240
+ end
1241
+
1242
+ def write_file(destination, content)
1243
+ FileUtils.mkdir_p(File.dirname(destination))
1244
+ File.write(destination, content)
1245
+ end
1246
+ end
1247
+ end
1248
+ end