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,696 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Branchout
4
+ # Static picture of one method: where it lives, which same-file callees it
5
+ # reaches, the literals in that slice, and the condition text for each
6
+ # decision Prism can see.
7
+ class Target
8
+ Parameter = Struct.new(:kind, :name, :default, :inferred_type, keyword_init: true)
9
+ Literals = Struct.new(:ints, :floats, :strings, keyword_init: true)
10
+ DefInfo = Struct.new(:const_name, :singleton, :node, keyword_init: true)
11
+ # An operator that branches where branch coverage counts no arm.
12
+ Operator = Struct.new(:loc, :line, :column, :op, :text, keyword_init: true)
13
+
14
+ STRING_METHODS = %i[
15
+ include? start_with? end_with? split strip lstrip rstrip chomp
16
+ match match? gsub sub scan upcase downcase chars to_i to_sym
17
+ index rindex
18
+ ].freeze
19
+ ARRAY_METHODS = %i[each map select reject first last flatten flat_map].freeze
20
+ HASH_METHODS = %i[keys values fetch each_pair key? value? merge].freeze
21
+ NUMERIC_OPS = %i[+ - * / % == != < <= > >=].freeze
22
+
23
+ ENTRY_RE = /\A([A-Z][A-Za-z0-9_]*(?:::[A-Z][A-Za-z0-9_]*)*)([.#])([A-Za-z_][A-Za-z0-9_]*[?!=]?)\z/
24
+
25
+ attr_reader :path, :display_path, :source, :const_name, :method_name, :singleton,
26
+ :parameters, :constructor, :ranges, :literals, :entry
27
+ # [singleton, name] of each call on self that no method in this file
28
+ # answers. Ruby's own lookup decides after load where each one lives.
29
+ attr_reader :outside_calls
30
+ # [namespace, class, name] of each call on an object the counted code
31
+ # builds with Class.new, kept in a local or an instance variable.
32
+ # +class+ is the constant as written, so Ruby resolves it in +namespace+.
33
+ attr_reader :typed_calls
34
+ # Each ||, &&, ||=, and &&= of the counted code. Coverage sees no side of
35
+ # them, so a method that branches only through them has no arm at all.
36
+ # An @ivar ||= or &&= on an ivar the file writes nowhere else is left
37
+ # out: it memoizes across calls on one object, and each input builds
38
+ # its own receiver, so no input can vary its left side.
39
+ attr_reader :operators
40
+ # Each @ivar ||= or &&= that OPERATORS leaves out, for the hint on a
41
+ # method whose only branch is a memo.
42
+ attr_reader :memos
43
+ # Each <, <=, >, and >= of the counted code. Coverage sees no side of
44
+ # them either, so every arm can be taken with no input where the sides
45
+ # are equal, where > and >= part.
46
+ attr_reader :boundaries
47
+
48
+ ENUMERABLE_CALLS = Enumerable.instance_methods.to_set.freeze
49
+
50
+ def self.parse(path, entry, type_overrides: nil)
51
+ new(path, entry, type_overrides: type_overrides).tap(&:parse)
52
+ end
53
+
54
+ def initialize(path, entry, type_overrides: nil)
55
+ @path = File.expand_path(path)
56
+ raise Error, "target file not found: #{path}" unless File.file?(@path)
57
+
58
+ @display_path = self.class.display_path(@path)
59
+ @source = File.read(@path)
60
+ @entry = entry
61
+ @type_overrides = type_overrides
62
+ match = ENTRY_RE.match(entry.to_s)
63
+ raise Error, "bad entry #{entry.inspect} (want Const.method or Const#method)" unless match
64
+
65
+ @const_name = match[1]
66
+ @singleton = match[2] == "."
67
+ @method_name = match[3].to_sym
68
+ end
69
+
70
+ def parse
71
+ result = Prism.parse(source)
72
+ unless result.success?
73
+ message = result.errors.map(&:message).join("; ")
74
+ raise Error, "failed to parse #{display_path}: #{message}"
75
+ end
76
+
77
+ defs = []
78
+ @superclasses = {}
79
+ @constant_values = Hash.new { |hash, key| hash[key] = [] }
80
+ collect_defs(result.value, [], false, defs)
81
+ index = {}
82
+ defs.each do |info|
83
+ key = [info.const_name, info.singleton, info.node.name]
84
+ if index.key?(key)
85
+ raise Error, "duplicate #{key[0]}#{key[1] ? "." : "#"}#{key[2]} in #{display_path}"
86
+ end
87
+ index[key] = info
88
+ end
89
+
90
+ lineage = lineage_of(const_name, defs)
91
+ # The first definition along the superclass chain, as Ruby finds it
92
+ # for the entry's own class.
93
+ find = ->(in_singleton, name) { lineage.lazy.filter_map { |const| index[[const, in_singleton, name]] }.first }
94
+ entry_def = find.(singleton, method_name)
95
+ unless entry_def
96
+ kind = singleton ? "." : "#"
97
+ raise Error, "cannot find #{const_name}#{kind}#{method_name} in #{display_path}"
98
+ end
99
+
100
+ @ranges = []
101
+ @outside_calls = []
102
+ argument_edges = []
103
+ @typed_calls = []
104
+ @literals = Literals.new(ints: [], floats: [], strings: [])
105
+ @conditions = {}
106
+ bodies = []
107
+ seen = {}
108
+ queue = [entry_def]
109
+ until queue.empty?
110
+ info = queue.shift
111
+ next if seen[info]
112
+ seen[info] = true
113
+
114
+ loc = info.node.location
115
+ @ranges << (loc.start_line..loc.end_line)
116
+ body = info.node.body
117
+ bodies << body if body
118
+ index_conditions(body) if body
119
+ built = info.singleton ? {} : built_ivars(defs, info.const_name)
120
+ calls_on_built(body, built).each { |const, name| @typed_calls << [info.const_name, const, name] } if body
121
+ bare_calls(body).each do |call|
122
+ callee = find.(info.singleton, call.name)
123
+ if callee
124
+ queue << callee
125
+ argument_edges << [call, callee.node.parameters]
126
+ else
127
+ @outside_calls << [info.singleton, call.name]
128
+ # Enumerable's methods are C, so they hide no arms of their own,
129
+ # but they run the each this file defines.
130
+ each = find.(info.singleton, :each) if ENUMERABLE_CALLS.include?(call.name)
131
+ queue << each if each
132
+ end
133
+ end
134
+ next unless info.singleton
135
+
136
+ calls_on_new(body).each do |name|
137
+ [:initialize, name].each do |instance_name|
138
+ callee = find.(false, instance_name)
139
+ queue << callee if callee
140
+ end
141
+ @outside_calls << [false, name] unless find.(false, name)
142
+ end
143
+ end
144
+
145
+ written = ivars_written(result.value)
146
+ @operators = bodies.flat_map { |body| operators_in(body, written) }.sort_by { |found| [found.line, found.column] }
147
+ @memos = bodies.flat_map { |body| operators_in(body, written, memos: true) }.sort_by { |found| [found.line, found.column] }
148
+ @boundaries = bodies.flat_map { |body| boundaries_in(body) }.sort_by { |found| [found.line, found.column] }
149
+ collect_literals(entry_def.node.parameters)
150
+ bodies.each { |body| collect_literals(body) }
151
+ @literals.ints = @literals.ints.uniq.sort
152
+ @literals.floats = @literals.floats.uniq.sort
153
+ @literals.strings = @literals.strings.uniq.sort
154
+ @parameters = build_parameters(entry_def.node.parameters, bodies, @type_overrides, argument_edges)
155
+ # The parameters of initialize, which an input's "new" fills for an
156
+ # instance method. Empty when this file does not define initialize.
157
+ init = singleton ? nil : index[[const_name, false, :initialize]]
158
+ @constructor = init ? build_parameters(init.node.parameters, [init.node.body].compact, nil) : []
159
+ self
160
+ end
161
+
162
+ def positional
163
+ parameters.select { |param| param.kind == :req || param.kind == :opt }
164
+ end
165
+
166
+ def covers_line?(line)
167
+ ranges.any? { |range| range.cover?(line) }
168
+ end
169
+
170
+ def condition_text(branch_type, bline, bcol, arm_type, aline, acol)
171
+ node = @conditions[[bline, bcol]]
172
+ text = condition_from(node, arm_type, aline, acol) if node
173
+ text = squash(source_line(bline)) if text.nil? || text.empty?
174
+ cut(text)
175
+ end
176
+
177
+ # An arm as a reviewer reads it, as its line and text: the keyword as
178
+ # written (elsif, a modifier unless), the condition, and the arm. A case
179
+ # arm sits on its when, in, or else line, and its condition already names
180
+ # it (when 1, case x else). A ternary is written whole.
181
+ def covered_arm(branch_type, bline, bcol, arm_type, aline, acol)
182
+ cond = condition_text(branch_type, bline, bcol, arm_type, aline, acol)
183
+ node = @conditions[[bline, bcol]]
184
+ case branch_type
185
+ when :case then [case_arm_line(node, arm_type, aline, acol) || bline, cond]
186
+ when :"&." then [bline, "#{cond} -> &. receiver #{arm_type == :then ? "present" : "nil"}"]
187
+ else
188
+ keyword = if node.nil? then branch_type.to_s
189
+ elsif node.respond_to?(:if_keyword) then node.if_keyword
190
+ else node.keyword
191
+ end
192
+ [bline, "#{keyword ? "#{keyword} #{cond}" : squash(node.slice)} -> #{arm_type}"]
193
+ end
194
+ end
195
+
196
+ def self.display_path(path)
197
+ absolute = File.expand_path(path)
198
+ root = "#{File.expand_path(Dir.pwd)}/"
199
+ return absolute.delete_prefix(root) if absolute.start_with?(root)
200
+
201
+ absolute
202
+ end
203
+
204
+ # The lines of the def that starts on +line+ of +path+, or nil when no
205
+ # def starts there, as with define_method.
206
+ def self.def_lines(path, line)
207
+ stack = [Prism.parse(File.read(path)).value]
208
+ until stack.empty?
209
+ node = stack.pop
210
+ loc = node.location
211
+ return loc.start_line..loc.end_line if node.type == :def_node && loc.start_line == line
212
+
213
+ stack.concat(node.compact_child_nodes)
214
+ end
215
+ nil
216
+ rescue SystemCallError
217
+ nil
218
+ end
219
+
220
+ private
221
+
222
+ def build_parameters(params_node, bodies, type_overrides, argument_edges = [])
223
+ list = []
224
+ return list unless params_node
225
+
226
+ params_node.requireds.each do |node|
227
+ list << Parameter.new(kind: :req, name: node.name, default: nil, inferred_type: nil)
228
+ end
229
+ params_node.optionals.each do |node|
230
+ list << Parameter.new(kind: :opt, name: node.name, default: literal_value(node.value), inferred_type: nil)
231
+ end
232
+ if params_node.rest
233
+ list << Parameter.new(kind: :rest, name: params_node.rest.name, default: nil, inferred_type: "array")
234
+ end
235
+ params_node.keywords.each do |node|
236
+ kind = node.type == :required_keyword_parameter_node ? :keyreq : :key
237
+ default = node.respond_to?(:value) ? literal_value(node.value) : nil
238
+ list << Parameter.new(kind: kind, name: node.name, default: default, inferred_type: nil)
239
+ end
240
+
241
+ evidence = Hash.new { |hash, key| hash[key] = Hash.new(0) }
242
+ bodies.each { |body| walk_evidence(body, evidence) }
243
+ # Reversed, so a helper's helper passes its evidence up the chain first.
244
+ argument_edges.reverse_each { |call, params| pass_evidence(call, params, evidence) }
245
+ positional_params = list.select { |param| param.kind == :req || param.kind == :opt }
246
+ if type_overrides
247
+ unless type_overrides.length == positional_params.length
248
+ raise Error, "types length #{type_overrides.length} does not match #{positional_params.length} positional parameters"
249
+ end
250
+ positional_params.each_with_index { |param, index| param.inferred_type = type_overrides[index] }
251
+ end
252
+
253
+ list.each do |param|
254
+ next if param.inferred_type
255
+
256
+ param.inferred_type = choose_type(param, evidence[param.name])
257
+ end
258
+ list
259
+ end
260
+
261
+ # +const+ and its superclasses declared in this file, nearest first. A
262
+ # superclass name resolves in the scope its class was written in.
263
+ def lineage_of(const, defs)
264
+ declared = defs.map(&:const_name) | @superclasses.keys
265
+ chain = [const]
266
+ while (parent = @superclasses[chain.last])
267
+ scope, text = parent
268
+ name = scope.length.downto(0).map { |depth| [*scope[0, depth], text].join("::") }.find { |candidate| declared.include?(candidate) }
269
+ break if name.nil? || chain.include?(name)
270
+
271
+ chain << name
272
+ end
273
+ chain
274
+ end
275
+
276
+ # The namespace a class or module body opens: class Cart::Line inside
277
+ # module Shop is Shop::Cart::Line, and class ::Top is Top anywhere.
278
+ def namespace_of(node, stack)
279
+ path = node.constant_path.slice
280
+ path.start_with?("::") ? [path.delete_prefix("::")] : stack + [path]
281
+ end
282
+
283
+ def choose_type(param, scores)
284
+ string = scores[:string]
285
+ array = scores[:array]
286
+ hash = scores[:hash]
287
+ float = scores[:float]
288
+ int = scores[:int]
289
+ if [string, array, hash, float, int].all?(&:zero?)
290
+ return type_from_default(param.default) || "int"
291
+ end
292
+
293
+ # String-specific calls beat numeric uses of the same name in a callee.
294
+ return "string" if string.positive? && string >= array && string >= hash && string >= int && string >= float
295
+ return "hash" if hash.positive? && hash >= array && hash >= int && hash >= float
296
+ return "array" if array.positive? && array >= int && array >= float
297
+ return "float" if float.positive? && float >= int
298
+
299
+ "int"
300
+ end
301
+
302
+ def type_from_default(value)
303
+ case value
304
+ when Integer then "int"
305
+ when Float then "float"
306
+ when String then "string"
307
+ when Array then "array"
308
+ when Hash then "hash"
309
+ when true, false then "bool"
310
+ end
311
+ end
312
+
313
+ def condition_from(node, arm_type, aline, acol)
314
+ case node.type
315
+ when :if_node, :unless_node, :while_node, :until_node
316
+ squash(node.predicate&.slice)
317
+ when :case_node, :case_match_node
318
+ if arm_type == :when || arm_type == :in
319
+ found = case_arm(node, aline, acol)
320
+ found ? squash(found.slice.lines.first) : nil
321
+ else
322
+ node.predicate ? "case #{squash(node.predicate.slice)} else" : "case else"
323
+ end
324
+ end
325
+ end
326
+
327
+ def case_arm(node, aline, acol)
328
+ node.conditions.find { |when_node| arm_matches?(when_node, aline, acol) }
329
+ end
330
+
331
+ # Coverage places a case arm at its body, not at its keyword.
332
+ def case_arm_line(node, arm_type, aline, acol)
333
+ return unless node
334
+
335
+ # prism before 1.0, the default gem of Ruby 3.3, names the else clause consequent.
336
+ else_clause = node.respond_to?(:else_clause) ? node.else_clause : node.consequent
337
+ arm = arm_type == :else ? else_clause : case_arm(node, aline, acol)
338
+ arm&.location&.start_line
339
+ end
340
+
341
+ # Coverage places a when or in arm at its body, or, when the body is
342
+ # empty, at the keyword. Ruby 3.3 puts an empty in arm at the end of its
343
+ # pattern instead.
344
+ def arm_matches?(when_node, aline, acol)
345
+ arm = [aline, acol]
346
+ body = when_node.statements&.location
347
+ return [body.start_line, body.start_column] == arm if body
348
+
349
+ keyword = when_node.location
350
+ pattern = when_node.pattern.location if when_node.type == :in_node
351
+ [keyword.start_line, keyword.start_column] == arm || (pattern && [pattern.end_line, pattern.end_column] == arm)
352
+ end
353
+
354
+ def index_conditions(node)
355
+ return if node.type == :def_node
356
+
357
+ if condition_node?(node)
358
+ loc = node.location
359
+ @conditions[[loc.start_line, loc.start_column]] = node
360
+ end
361
+ node.compact_child_nodes.each { |child| index_conditions(child) }
362
+ end
363
+
364
+ def condition_node?(node)
365
+ %i[if_node unless_node while_node until_node case_node case_match_node].include?(node.type)
366
+ end
367
+
368
+ # The instance variables the file sets other than with ||= or &&=.
369
+ def ivars_written(node, acc = [])
370
+ acc << node.name if %i[instance_variable_write_node instance_variable_operator_write_node instance_variable_target_node].include?(node.type)
371
+ node.compact_child_nodes.each { |child| ivars_written(child, acc) }
372
+ acc
373
+ end
374
+
375
+ # +memos+ picks the memos instead of the operators.
376
+ def operators_in(node, written, acc = [], memos: false)
377
+ return acc if node.type == :def_node
378
+
379
+ memo = %i[instance_variable_or_write_node instance_variable_and_write_node].include?(node.type) && !written.include?(node.name)
380
+ branching = node.type == :or_node || node.type == :and_node || node.type.to_s.end_with?("_or_write_node", "_and_write_node")
381
+ if branching && memo == memos
382
+ operator = node.operator_loc
383
+ line = operator.start_line
384
+ column = operator.start_column
385
+ acc << Operator.new(loc: "#{display_path}:#{line}:#{column}", line: line, column: column, op: operator.slice,
386
+ text: cut(squash(node.slice)))
387
+ end
388
+ node.compact_child_nodes.each { |child| operators_in(child, written, acc, memos: memos) }
389
+ acc
390
+ end
391
+
392
+ COMPARISONS = %i[< <= > >=].freeze
393
+
394
+ def boundaries_in(node, acc = [])
395
+ return acc if node.type == :def_node
396
+
397
+ if node.type == :call_node && COMPARISONS.include?(node.name) && node.receiver && node.arguments&.arguments&.length == 1
398
+ operator = node.message_loc
399
+ acc << Operator.new(loc: "#{display_path}:#{operator.start_line}:#{operator.start_column}", line: operator.start_line,
400
+ column: operator.start_column, op: operator.slice, text: cut(squash(node.slice)))
401
+ end
402
+ node.compact_child_nodes.each { |child| boundaries_in(child, acc) }
403
+ acc
404
+ end
405
+
406
+ def collect_literals(node)
407
+ return unless node.is_a?(Prism::Node)
408
+ return if node.type == :def_node
409
+
410
+ case node.type
411
+ when :integer_node
412
+ @literals.ints << node.value
413
+ when :float_node
414
+ # 1e400 parses to Infinity, which JSON cannot write as an input.
415
+ @literals.floats << node.value if node.value.finite?
416
+ when :string_node
417
+ @literals.strings << node.unescaped
418
+ when :constant_read_node
419
+ Array(@constant_values[node.name]).each { |value| collect_literals(value) }
420
+ end
421
+ node.compact_child_nodes.each { |child| collect_literals(child) }
422
+ end
423
+
424
+ def bare_calls(node, acc = [])
425
+ return acc unless node.is_a?(Prism::Node)
426
+ return acc if node.type == :def_node
427
+
428
+ if node.type == :call_node
429
+ receiver = node.receiver
430
+ acc << node if receiver.nil? || receiver.type == :self_node
431
+ end
432
+ node.compact_child_nodes.each { |child| bare_calls(child, acc) }
433
+ acc
434
+ end
435
+
436
+ # Instance variables that a method of +const+ in this file sets to
437
+ # Class.new, as initialize does for a collaborator: name => class text.
438
+ def built_ivars(defs, const)
439
+ defs.select { |info| info.const_name == const && !info.singleton && info.node.body }
440
+ .flat_map { |info| assignments(info.node.body, :instance_variable_write_node) }.to_h
441
+ end
442
+
443
+ # [class text, method name] of each call on a value that this body
444
+ # builds with Class.new: a local it assigns, an instance variable in
445
+ # +ivars+, or the new call itself.
446
+ def calls_on_built(body, ivars)
447
+ locals = assignments(body, :local_variable_write_node).to_h
448
+ found = []
449
+ walk = lambda do |node|
450
+ next if node.type == :def_node
451
+
452
+ if node.type == :call_node && (receiver = node.receiver)
453
+ const = case receiver.type
454
+ when :local_variable_read_node then locals[receiver.name]
455
+ when :instance_variable_read_node then ivars[receiver.name]
456
+ when :call_node then built_class(receiver)
457
+ end
458
+ found << [const, node.name] if const
459
+ end
460
+ node.compact_child_nodes.each { |child| walk.(child) }
461
+ end
462
+ walk.(body)
463
+ found.uniq
464
+ end
465
+
466
+ def assignments(node, type, acc = [])
467
+ return acc if node.type == :def_node
468
+
469
+ if node.type == type && (const = built_class(node.value))
470
+ acc << [node.name, const]
471
+ end
472
+ node.compact_child_nodes.each { |child| assignments(child, type, acc) }
473
+ acc
474
+ end
475
+
476
+ # The class text of Class.new(...), or nil.
477
+ def built_class(node)
478
+ return nil unless node&.type == :call_node && node.name == :new
479
+ return nil unless %i[constant_read_node constant_path_node].include?(node.receiver&.type)
480
+
481
+ node.receiver.slice.delete_prefix("::")
482
+ end
483
+
484
+ # Methods a class method calls on an instance it builds itself, as in
485
+ # new(doc).apply, or new(path).tap(&:parse).
486
+ def calls_on_new(node, acc = [])
487
+ return acc unless node.is_a?(Prism::Node)
488
+ return acc if node.type == :def_node
489
+
490
+ if node.type == :call_node && node.receiver&.type == :call_node
491
+ maker = node.receiver
492
+ acc << called_on_receiver(node) if maker.name == :new && (maker.receiver.nil? || maker.receiver.type == :self_node)
493
+ end
494
+ node.compact_child_nodes.each { |child| calls_on_new(child, acc) }
495
+ acc
496
+ end
497
+
498
+ # tap(&:parse) and then(&:parse) call parse on the receiver.
499
+ def called_on_receiver(call)
500
+ block = call.block
501
+ return call.name unless %i[tap then].include?(call.name) && block&.type == :block_argument_node
502
+
503
+ block.expression&.type == :symbol_node ? block.expression.unescaped.to_sym : call.name
504
+ end
505
+
506
+ def walk_evidence(node, evidence)
507
+ return if node.type == :def_node
508
+
509
+ if node.type == :call_node
510
+ note_call(node, evidence)
511
+ elsif node.type == :case_node || node.type == :case_match_node
512
+ note_case(node, evidence)
513
+ end
514
+ node.compact_child_nodes.each { |child| walk_evidence(child, evidence) }
515
+ end
516
+
517
+ def note_case(node, evidence)
518
+ name = lvar_name(node.predicate)
519
+ return unless name
520
+
521
+ node.conditions.each do |when_node|
522
+ next unless when_node.respond_to?(:conditions)
523
+
524
+ Array(when_node.conditions).each do |condition|
525
+ case condition.type
526
+ when :string_node then evidence[name][:string] += 1
527
+ when :integer_node then evidence[name][:int] += 1
528
+ when :float_node then evidence[name][:float] += 1
529
+ end
530
+ end
531
+ end
532
+ end
533
+
534
+ def note_call(node, evidence)
535
+ receiver_name = lvar_name(node.receiver)
536
+ op = node.name
537
+ if receiver_name
538
+ if STRING_METHODS.include?(op) || (op == :count && contains?(node, :string_node))
539
+ evidence[receiver_name][:string] += 1
540
+ elsif ARRAY_METHODS.include?(op)
541
+ evidence[receiver_name][:array] += 1
542
+ elsif HASH_METHODS.include?(op) || (%i[[] dig].include?(op) && %i[string_node symbol_node].include?(first_arg(node)&.type))
543
+ evidence[receiver_name][:hash] += 1
544
+ elsif NUMERIC_OPS.include?(op)
545
+ arg = first_arg(node)
546
+ if contains?(arg, :float_node)
547
+ evidence[receiver_name][:float] += 1
548
+ elsif contains?(arg, :string_node)
549
+ evidence[receiver_name][:string] += 1
550
+ else
551
+ evidence[receiver_name][:int] += 1
552
+ end
553
+ end
554
+ end
555
+
556
+ return unless NUMERIC_OPS.include?(op)
557
+
558
+ Array(node.arguments&.arguments).each do |arg|
559
+ name = lvar_name(arg)
560
+ next unless name
561
+
562
+ if contains?(node.receiver, :float_node)
563
+ evidence[name][:float] += 1
564
+ elsif contains?(node.receiver, :string_node)
565
+ evidence[name][:string] += 1
566
+ elsif contains?(node.receiver, :integer_node)
567
+ evidence[name][:int] += 1
568
+ end
569
+ end
570
+ end
571
+
572
+ # held?(code) with def held?(state) = state == "held" makes code a
573
+ # String too: a positional argument takes the evidence of the
574
+ # parameter it fills.
575
+ def pass_evidence(call, params, evidence)
576
+ return unless params
577
+
578
+ names = (params.requireds + params.optionals).map { |param| param.name if param.respond_to?(:name) }
579
+ Array(call.arguments&.arguments).each_with_index do |arg, index|
580
+ # Past a splat, an argument's position no longer names its parameter.
581
+ break if arg.type == :splat_node
582
+
583
+ name = lvar_name(arg)
584
+ evidence[names[index]].each { |kind, count| evidence[name][kind] += count } if name
585
+ end
586
+ end
587
+
588
+ def first_arg(node)
589
+ node.arguments&.arguments&.first
590
+ end
591
+
592
+ def lvar_name(node)
593
+ return unless node.is_a?(Prism::Node) && node.type == :local_variable_read_node
594
+
595
+ node.name
596
+ end
597
+
598
+ def contains?(node, type)
599
+ return false unless node.is_a?(Prism::Node)
600
+ return true if node.type == type
601
+ return true if node.type == :constant_read_node && Array(@constant_values[node.name]).any? { |value| value.type == type }
602
+
603
+ node.compact_child_nodes.any? { |child| contains?(child, type) }
604
+ end
605
+
606
+ # A container default stands in as an empty one of its kind: the type is
607
+ # what inference needs, and every mutated input passes the slot anyway.
608
+ def literal_value(node)
609
+ case node&.type
610
+ when :integer_node then node.value
611
+ when :float_node then node.value
612
+ when :string_node then node.unescaped
613
+ when :true_node then true
614
+ when :false_node then false
615
+ when :array_node then []
616
+ when :hash_node then {}
617
+ end
618
+ end
619
+
620
+ # functions tracks module_function and extend self in the current module
621
+ # body: a bare module_function makes every later def a class method too,
622
+ # named arguments make those defs class methods too, and extend self
623
+ # makes every def in the body one, including those above it.
624
+ def collect_defs(node, stack, in_singleton, acc, functions = nil)
625
+ case node.type
626
+ when :module_node, :class_node
627
+ inner = namespace_of(node, stack)
628
+ @superclasses[inner.join("::")] = [stack, node.superclass.slice.delete_prefix("::")] if node.type == :class_node && node.superclass
629
+ return unless node.body
630
+
631
+ functions = { after: false, names: [], all: false }
632
+ collect_defs(node.body, inner, false, acc, functions)
633
+ named = acc.select do |info|
634
+ info.const_name == inner.join("::") && !info.singleton &&
635
+ (functions[:all] || functions[:names].include?(info.node.name))
636
+ end
637
+ named.each do |info|
638
+ next if acc.any? { |other| other.singleton && other.node.equal?(info.node) }
639
+
640
+ acc << DefInfo.new(const_name: info.const_name, singleton: true, node: info.node)
641
+ end
642
+ when :singleton_class_node
643
+ collect_defs(node.body, stack, true, acc) if node.body
644
+ when :constant_write_node, :constant_path_write_node
645
+ body = class_block_body(node.value)
646
+ name = node.type == :constant_write_node ? node.name.to_s : node.target.slice.delete_prefix("::")
647
+ note_constant(node) if node.type == :constant_write_node
648
+ body ? collect_defs(body, stack + [name], false, acc) : collect_defs(node.value, stack, in_singleton, acc, functions)
649
+ when :def_node
650
+ singleton = in_singleton || node.receiver&.type == :self_node
651
+ acc << DefInfo.new(const_name: stack.join("::"), singleton: singleton, node: node)
652
+ acc << DefInfo.new(const_name: stack.join("::"), singleton: true, node: node) if !singleton && functions&.dig(:after)
653
+ else
654
+ if functions && node.type == :call_node && node.name == :module_function && node.receiver.nil?
655
+ arguments = node.arguments&.arguments
656
+ functions[:after] = true unless arguments
657
+ Array(arguments).each { |arg| functions[:names] << arg.unescaped.to_sym if arg.type == :symbol_node }
658
+ end
659
+ if functions && node.type == :call_node && node.name == :extend && node.receiver.nil? &&
660
+ node.arguments&.arguments&.map(&:type) == [:self_node]
661
+ functions[:all] = true
662
+ end
663
+ node.compact_child_nodes.each { |child| collect_defs(child, stack, in_singleton, acc, functions) }
664
+ end
665
+ end
666
+
667
+ # NAME = literal, by name alone: a helper's comparison against CLOSED is
668
+ # a comparison against its literal, wherever the file sets CLOSED.
669
+ def note_constant(node)
670
+ value = node.value
671
+ value = value.receiver if value.type == :call_node && value.name == :freeze && value.receiver
672
+ @constant_values[node.name] << value if %i[integer_node float_node string_node].include?(value&.type)
673
+ end
674
+
675
+ # The body of the block in Struct.new(...) do ... end, Data.define, or
676
+ # Class.new, whose defs are methods of the class the call builds.
677
+ CLASS_BUILDERS = { "Struct" => :new, "Data" => :define, "Class" => :new }.freeze
678
+
679
+ def class_block_body(value)
680
+ return unless value.type == :call_node && value.block&.type == :block_node && value.receiver
681
+
682
+ receiver = value.receiver.slice.delete_prefix("::")
683
+ value.block.body if CLASS_BUILDERS[receiver] == value.name
684
+ end
685
+
686
+ def source_line(line)
687
+ source.lines[line - 1].to_s
688
+ end
689
+
690
+ def cut(text) = text.length > 160 ? "#{text[0, 157]}..." : text
691
+
692
+ def squash(text)
693
+ text.to_s.gsub(/\s+/, " ").strip
694
+ end
695
+ end
696
+ end