branchout 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/AGENTS.md +38 -0
- data/LICENSE +21 -0
- data/README.ja.md +903 -0
- data/README.md +586 -0
- data/bin/branchout +15 -0
- data/examples/arithmetic.rb +24 -0
- data/examples/hidden_bug.rb +17 -0
- data/examples/inputs/arithmetic.json +10 -0
- data/examples/inputs/arithmetic.seed.json +5 -0
- data/examples/inputs/hidden_bug.json +9 -0
- data/examples/inputs/hidden_bug.seed.json +5 -0
- data/examples/inputs/magic_value.json +7 -0
- data/examples/inputs/magic_value.seed.json +5 -0
- data/examples/inputs/version_parser.json +11 -0
- data/examples/inputs/version_parser.seed.json +5 -0
- data/examples/magic_value.rb +11 -0
- data/examples/version_parser.rb +29 -0
- data/lib/branchout/canon.rb +59 -0
- data/lib/branchout/cli.rb +302 -0
- data/lib/branchout/coverage.rb +9 -0
- data/lib/branchout/coverage_session.rb +49 -0
- data/lib/branchout/emitters/minitest.rb +92 -0
- data/lib/branchout/emitters/rspec.rb +532 -0
- data/lib/branchout/emitters/support.rb +1248 -0
- data/lib/branchout/emitters.rb +101 -0
- data/lib/branchout/engine.rb +303 -0
- data/lib/branchout/explore.rb +124 -0
- data/lib/branchout/input.rb +215 -0
- data/lib/branchout/input_file.rb +125 -0
- data/lib/branchout/mutator.rb +124 -0
- data/lib/branchout/prism_choice.rb +25 -0
- data/lib/branchout/report.rb +449 -0
- data/lib/branchout/rng.rb +31 -0
- data/lib/branchout/runner.rb +591 -0
- data/lib/branchout/sampler.rb +70 -0
- data/lib/branchout/target.rb +696 -0
- data/lib/branchout/version.rb +8 -0
- data/lib/branchout.rb +31 -0
- data/skills/branchout/SKILL.md +230 -0
- data/skills/branchout/inputs.example.json +10 -0
- metadata +114 -0
|
@@ -0,0 +1,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
|