pinspec 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/CHANGELOG.md +133 -0
- data/LICENSE.txt +21 -0
- data/README.md +183 -0
- data/exe/pinspec +8 -0
- data/lib/pinspec/analyzer/app_profile_reader.rb +348 -0
- data/lib/pinspec/analyzer/factory_registry.rb +288 -0
- data/lib/pinspec/analyzer/inflector.rb +85 -0
- data/lib/pinspec/analyzer/schema_reader.rb +444 -0
- data/lib/pinspec/analyzer/source.rb +56 -0
- data/lib/pinspec/analyzer/target_parser.rb +710 -0
- data/lib/pinspec/cli.rb +585 -0
- data/lib/pinspec/emit/namer.rb +103 -0
- data/lib/pinspec/emit/spec_writer.rb +504 -0
- data/lib/pinspec/emit/stability_filter.rb +183 -0
- data/lib/pinspec/errors.rb +85 -0
- data/lib/pinspec/inputs/boundary.rb +112 -0
- data/lib/pinspec/inputs/corpus.rb +148 -0
- data/lib/pinspec/inputs/hydrator.rb +197 -0
- data/lib/pinspec/inputs/redactor.rb +138 -0
- data/lib/pinspec/inputs/sample_runner.rb +98 -0
- data/lib/pinspec/inputs/sampler.rb +187 -0
- data/lib/pinspec/report/summary.rb +348 -0
- data/lib/pinspec/runner/capture.rb +127 -0
- data/lib/pinspec/runner/probe_generator.rb +662 -0
- data/lib/pinspec/runner/sandbox.rb +121 -0
- data/lib/pinspec/setup/context_builder.rb +471 -0
- data/lib/pinspec/setup/dependency_resolver.rb +236 -0
- data/lib/pinspec/tags.rb +103 -0
- data/lib/pinspec/types.rb +497 -0
- data/lib/pinspec/validate/mutation_adapter.rb +108 -0
- data/lib/pinspec/validate/pin_scorer.rb +164 -0
- data/lib/pinspec/verify/verifier.rb +149 -0
- data/lib/pinspec/version.rb +8 -0
- data/lib/pinspec.rb +17 -0
- data/templates/factory_build.rb +54 -0
- data/templates/serializer.rb +243 -0
- data/templates/spec_support.rb +83 -0
- metadata +134 -0
|
@@ -0,0 +1,710 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module Pinspec
|
|
6
|
+
module Analyzer
|
|
7
|
+
class TargetParser
|
|
8
|
+
include Source
|
|
9
|
+
|
|
10
|
+
MODEL_BASES = ["ApplicationRecord", "ActiveRecord::Base"].freeze
|
|
11
|
+
|
|
12
|
+
INERT_BASES = %w[Object BasicObject].freeze
|
|
13
|
+
|
|
14
|
+
CLOCK_CALLS = {
|
|
15
|
+
"Time" => %i[now new],
|
|
16
|
+
"Date" => %i[today],
|
|
17
|
+
"DateTime" => %i[now]
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
VISIBILITIES = %i[public private protected].freeze
|
|
21
|
+
|
|
22
|
+
SCALARISH_NAMES = %w[
|
|
23
|
+
amount total subtotal price cost quantity qty count index number num
|
|
24
|
+
name email phone title body text message description reason code type
|
|
25
|
+
kind status state value key data payload options opts args params attrs
|
|
26
|
+
attributes config settings id token flag mode format scope limit offset
|
|
27
|
+
date time now today percent rate ratio sum size length label url path
|
|
28
|
+
].freeze
|
|
29
|
+
|
|
30
|
+
DI_PATTERNS = [
|
|
31
|
+
/\ARails\.application\.config\b/,
|
|
32
|
+
/\ARails\.configuration\b/,
|
|
33
|
+
/Container\b.*\.resolve\b/,
|
|
34
|
+
/\AContainer\./
|
|
35
|
+
].freeze
|
|
36
|
+
|
|
37
|
+
PRUNE_AT = [
|
|
38
|
+
Prism::DefNode,
|
|
39
|
+
Prism::ClassNode,
|
|
40
|
+
Prism::ModuleNode,
|
|
41
|
+
Prism::SingletonClassNode
|
|
42
|
+
].freeze
|
|
43
|
+
|
|
44
|
+
Descriptor = Data.define(:class_name, :method_name, :singleton)
|
|
45
|
+
|
|
46
|
+
Scope = Struct.new(
|
|
47
|
+
:name, :node, :kind, :superclass_slice, :superclass_node, :parent,
|
|
48
|
+
keyword_init: true
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
MethodDef = Struct.new(
|
|
52
|
+
:scope_name, :name, :node, :singleton, :visibility,
|
|
53
|
+
keyword_init: true
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
Delegation = Struct.new(:scope_name, :method, :to, :line, keyword_init: true)
|
|
57
|
+
|
|
58
|
+
class << self
|
|
59
|
+
def parse(file_path, method_name)
|
|
60
|
+
new(file_path, method_name).parse
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def split_target(target)
|
|
64
|
+
unless target.to_s.include?("#")
|
|
65
|
+
raise ArgumentError,
|
|
66
|
+
"target must be FILE#METHOD (got #{target.inspect}); " \
|
|
67
|
+
"e.g. app/services/invoice_calculator.rb#call"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
target.split("#", 2)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def initialize(file_path, method_name)
|
|
75
|
+
@file_path = file_path
|
|
76
|
+
@descriptor = parse_descriptor(method_name)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def parse
|
|
80
|
+
read_source
|
|
81
|
+
build_index
|
|
82
|
+
|
|
83
|
+
mdef = select_definition
|
|
84
|
+
scope = @scopes[mdef.scope_name]
|
|
85
|
+
|
|
86
|
+
unless scope
|
|
87
|
+
raise TargetNotFound,
|
|
88
|
+
"`#{@descriptor.method_name}` is defined at the top level of " \
|
|
89
|
+
"#{@file_path}, not inside a class or module; pinspec has no " \
|
|
90
|
+
"receiver to build."
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
guard_block!(mdef, scope)
|
|
94
|
+
|
|
95
|
+
construction_kind, initializer_params = resolve_construction(scope, mdef)
|
|
96
|
+
init_node = find_initialize(scope.name)
|
|
97
|
+
|
|
98
|
+
scan = [mdef.node, init_node].compact
|
|
99
|
+
|
|
100
|
+
TargetProfile.new(
|
|
101
|
+
file_path: @file_path,
|
|
102
|
+
class_name: scope.name,
|
|
103
|
+
method_name: mdef.name,
|
|
104
|
+
params: params_of(mdef.node),
|
|
105
|
+
initializer_params: initializer_params,
|
|
106
|
+
construction_kind: construction_kind,
|
|
107
|
+
visibility: mdef.visibility,
|
|
108
|
+
takes_block: false,
|
|
109
|
+
source_range: [mdef.node.location.start_line, mdef.node.location.end_line],
|
|
110
|
+
referenced_constants: referenced_constants(scan),
|
|
111
|
+
clock_sites: clock_sites(scan)
|
|
112
|
+
)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
def parse_descriptor(raw)
|
|
118
|
+
spec = raw.to_s.sub(/\A#/, "")
|
|
119
|
+
|
|
120
|
+
if spec.include?("#")
|
|
121
|
+
klass, meth = spec.split("#", 2)
|
|
122
|
+
Descriptor.new(class_name: klass, method_name: meth.to_sym, singleton: false)
|
|
123
|
+
elsif spec.include?(".")
|
|
124
|
+
klass, meth = spec.split(".", 2)
|
|
125
|
+
Descriptor.new(class_name: klass, method_name: meth.to_sym, singleton: true)
|
|
126
|
+
else
|
|
127
|
+
Descriptor.new(class_name: nil, method_name: spec.to_sym, singleton: nil)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def read_source
|
|
132
|
+
unless File.file?(@file_path)
|
|
133
|
+
raise TargetNotFound, "no such file: #{@file_path}"
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
@source = Source.read(@file_path)
|
|
137
|
+
result = Prism.parse(@source)
|
|
138
|
+
|
|
139
|
+
unless result.success?
|
|
140
|
+
first = result.errors.first
|
|
141
|
+
raise UnparsableSource,
|
|
142
|
+
"#{@file_path} is not valid Ruby: #{first.message} " \
|
|
143
|
+
"(line #{first.location.start_line})"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
@program = result.value
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def build_index
|
|
150
|
+
@scopes = {}
|
|
151
|
+
@defs = []
|
|
152
|
+
@delegations = []
|
|
153
|
+
@meta_scopes = []
|
|
154
|
+
@overrides = {}
|
|
155
|
+
|
|
156
|
+
walk_body(@program.statements, nil)
|
|
157
|
+
|
|
158
|
+
@overrides.each do |(scope_name, name), visibility|
|
|
159
|
+
@defs.each do |d|
|
|
160
|
+
d.visibility = visibility if d.scope_name == scope_name && d.name == name && !d.singleton
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def walk_body(statements, scope_name)
|
|
166
|
+
mode = :public
|
|
167
|
+
|
|
168
|
+
Array(statements&.body).each do |stmt|
|
|
169
|
+
case stmt
|
|
170
|
+
when Prism::ClassNode, Prism::ModuleNode
|
|
171
|
+
register_scope(stmt, scope_name)
|
|
172
|
+
when Prism::SingletonClassNode
|
|
173
|
+
walk_singleton(stmt, scope_name)
|
|
174
|
+
when Prism::DefNode
|
|
175
|
+
register_def(stmt, scope_name, mode)
|
|
176
|
+
when Prism::CallNode
|
|
177
|
+
switched = visibility_switch(stmt)
|
|
178
|
+
if switched
|
|
179
|
+
mode = switched
|
|
180
|
+
else
|
|
181
|
+
handle_macro(stmt, scope_name)
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def register_scope(node, parent_name)
|
|
188
|
+
name = [parent_name, node.constant_path.slice].compact.join("::")
|
|
189
|
+
klass = node.is_a?(Prism::ClassNode)
|
|
190
|
+
|
|
191
|
+
@scopes[name] = Scope.new(
|
|
192
|
+
name: name,
|
|
193
|
+
node: node,
|
|
194
|
+
kind: klass ? :class : :module,
|
|
195
|
+
superclass_slice: klass ? node.superclass&.slice : nil,
|
|
196
|
+
superclass_node: klass ? node.superclass : nil,
|
|
197
|
+
parent: parent_name
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
walk_body(node.body, name)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def walk_singleton(node, scope_name)
|
|
204
|
+
mode = :public
|
|
205
|
+
|
|
206
|
+
Array(node.body&.body).each do |stmt|
|
|
207
|
+
case stmt
|
|
208
|
+
when Prism::DefNode
|
|
209
|
+
@defs << MethodDef.new(
|
|
210
|
+
scope_name: scope_name, name: stmt.name, node: stmt,
|
|
211
|
+
singleton: true, visibility: mode
|
|
212
|
+
)
|
|
213
|
+
when Prism::CallNode
|
|
214
|
+
switched = visibility_switch(stmt)
|
|
215
|
+
mode = switched if switched
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def register_def(node, scope_name, mode, forced_visibility: nil)
|
|
221
|
+
singleton = !node.receiver.nil?
|
|
222
|
+
|
|
223
|
+
if %i[method_missing respond_to_missing?].include?(node.name)
|
|
224
|
+
@meta_scopes << scope_name
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
@defs << MethodDef.new(
|
|
228
|
+
scope_name: scope_name,
|
|
229
|
+
name: node.name,
|
|
230
|
+
node: node,
|
|
231
|
+
singleton: singleton,
|
|
232
|
+
visibility: forced_visibility || (singleton ? :public : mode)
|
|
233
|
+
)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def visibility_switch(call)
|
|
237
|
+
return nil unless call.receiver.nil?
|
|
238
|
+
return nil unless VISIBILITIES.include?(call.name)
|
|
239
|
+
return nil if call.arguments && !call.arguments.arguments.empty?
|
|
240
|
+
|
|
241
|
+
call.name
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def handle_macro(call, scope_name)
|
|
245
|
+
return unless call.receiver.nil?
|
|
246
|
+
|
|
247
|
+
args = Array(call.arguments&.arguments)
|
|
248
|
+
|
|
249
|
+
if VISIBILITIES.include?(call.name) && !args.empty?
|
|
250
|
+
args.each do |arg|
|
|
251
|
+
case arg
|
|
252
|
+
when Prism::DefNode
|
|
253
|
+
register_def(arg, scope_name, call.name, forced_visibility: call.name)
|
|
254
|
+
when Prism::SymbolNode
|
|
255
|
+
@overrides[[scope_name, arg.unescaped.to_sym]] = call.name
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
return
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
return unless call.name == :delegate
|
|
262
|
+
|
|
263
|
+
to = delegate_target(args)
|
|
264
|
+
args.grep(Prism::SymbolNode).each do |sym|
|
|
265
|
+
@delegations << Delegation.new(
|
|
266
|
+
scope_name: scope_name,
|
|
267
|
+
method: sym.unescaped.to_sym,
|
|
268
|
+
to: to,
|
|
269
|
+
line: call.location.start_line
|
|
270
|
+
)
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def delegate_target(args)
|
|
275
|
+
hash = args.grep(Prism::KeywordHashNode).first
|
|
276
|
+
return nil unless hash
|
|
277
|
+
|
|
278
|
+
assoc = hash.elements.grep(Prism::AssocNode).find do |a|
|
|
279
|
+
a.key.is_a?(Prism::SymbolNode) && a.key.unescaped == "to"
|
|
280
|
+
end
|
|
281
|
+
return nil unless assoc
|
|
282
|
+
|
|
283
|
+
value = assoc.value
|
|
284
|
+
value.is_a?(Prism::SymbolNode) ? value.unescaped : value.slice
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def select_definition
|
|
288
|
+
candidates = @defs.select { |d| d.name == @descriptor.method_name }
|
|
289
|
+
|
|
290
|
+
if @descriptor.class_name
|
|
291
|
+
scope = resolve_named_scope(@descriptor.class_name)
|
|
292
|
+
candidates = candidates.select { |d| d.scope_name == scope.name }
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
unless @descriptor.singleton.nil?
|
|
296
|
+
candidates = candidates.select { |d| d.singleton == @descriptor.singleton }
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
raise not_found_error if candidates.empty?
|
|
300
|
+
raise ambiguous_error(candidates) if candidates.size > 1
|
|
301
|
+
|
|
302
|
+
candidates.first
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def resolve_named_scope(name)
|
|
306
|
+
return @scopes[name] if @scopes.key?(name)
|
|
307
|
+
|
|
308
|
+
suffix = @scopes.keys.select { |k| k.end_with?("::#{name}") }
|
|
309
|
+
return @scopes[suffix.first] if suffix.size == 1
|
|
310
|
+
|
|
311
|
+
if suffix.size > 1
|
|
312
|
+
raise AmbiguousTarget,
|
|
313
|
+
"`#{name}` matches #{suffix.join(', ')} in #{@file_path}; " \
|
|
314
|
+
"qualify it fully."
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
raise TargetNotFound,
|
|
318
|
+
"no class or module named `#{name}` in #{@file_path}. " \
|
|
319
|
+
"Found: #{@scopes.keys.join(', ')}"
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def not_found_error
|
|
323
|
+
method = @descriptor.method_name
|
|
324
|
+
parts = ["no method `#{method}` in #{@file_path}"]
|
|
325
|
+
|
|
326
|
+
delegation = @delegations.find { |d| d.method == method }
|
|
327
|
+
if delegation
|
|
328
|
+
parts << "`#{delegation.scope_name}` delegates :#{method} to " \
|
|
329
|
+
"`#{delegation.to}` (line #{delegation.line})#{delegation_hint(delegation)}"
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
if @meta_scopes.any?
|
|
333
|
+
parts << "#{@meta_scopes.compact.uniq.join(', ')} defines method_missing, " \
|
|
334
|
+
"so `#{method}` may be handled dynamically; pinspec cannot see " \
|
|
335
|
+
"dynamic methods and will not guess at one"
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
known = @defs.first(12).map { |d| "#{d.scope_name}#{d.singleton ? '.' : '#'}#{d.name} " \
|
|
339
|
+
"(line #{d.node.location.start_line})" }
|
|
340
|
+
parts << "methods found: #{known.join(', ')}" if known.any?
|
|
341
|
+
|
|
342
|
+
TargetNotFound.new(parts.join(". "))
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def delegation_hint(delegation)
|
|
346
|
+
to = delegation.to.to_s
|
|
347
|
+
return "" if to.empty?
|
|
348
|
+
|
|
349
|
+
if to.match?(/\A[A-Z]/)
|
|
350
|
+
file = to.gsub("::", "/").gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase
|
|
351
|
+
" - try the definition of #{to}, conventionally #{file}.rb"
|
|
352
|
+
else
|
|
353
|
+
" - `#{to}` is assigned at runtime, so pin the method on whatever class " \
|
|
354
|
+
"it holds, or pin this class's caller"
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def ambiguous_error(candidates)
|
|
359
|
+
listed = candidates.map do |d|
|
|
360
|
+
sep = d.singleton ? "." : "#"
|
|
361
|
+
"#{@file_path}##{d.scope_name}#{sep}#{d.name} (line #{d.node.location.start_line})"
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
AmbiguousTarget.new(
|
|
365
|
+
"`#{@descriptor.method_name}` resolves to #{candidates.size} definitions " \
|
|
366
|
+
"in #{@file_path}: #{listed.join(', ')}. Re-run with one of them."
|
|
367
|
+
)
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def guard_block!(mdef, scope)
|
|
371
|
+
block_param = mdef.node.parameters&.block
|
|
372
|
+
yield_node = find_within(mdef.node.body, Prism::YieldNode)
|
|
373
|
+
return unless block_param || yield_node
|
|
374
|
+
|
|
375
|
+
detail =
|
|
376
|
+
if block_param
|
|
377
|
+
"takes a block parameter (&#{block_param.name})"
|
|
378
|
+
else
|
|
379
|
+
"yields (line #{yield_node.location.start_line})"
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
raise BlockRequired,
|
|
383
|
+
"#{scope.name}##{mdef.name} #{detail}. Blocks can't cross the " \
|
|
384
|
+
"probe/spec boundary; extract the block body into its own method " \
|
|
385
|
+
"and pin that, or pin this method's caller."
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
def resolve_construction(scope, mdef)
|
|
389
|
+
return [:class_method, []] if mdef.singleton
|
|
390
|
+
return [:model_instance, []] if model_scope?(scope)
|
|
391
|
+
|
|
392
|
+
members = struct_members(scope)
|
|
393
|
+
return [:struct, members] if members
|
|
394
|
+
|
|
395
|
+
return [:interactor, interactor_params(scope)] if interactor?(scope)
|
|
396
|
+
return [:dry_initializer, dry_params(scope)] if dry_initializer?(scope)
|
|
397
|
+
|
|
398
|
+
init = find_initialize(scope.name)
|
|
399
|
+
if init
|
|
400
|
+
guard_opaque_initialize!(scope, init)
|
|
401
|
+
return [:new, params_of(init)]
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
inherited_construction(scope)
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
def inherited_construction(scope)
|
|
408
|
+
sup = scope.superclass_slice
|
|
409
|
+
return [:new, []] if sup.nil? || INERT_BASES.include?(sup)
|
|
410
|
+
|
|
411
|
+
parent = resolve_scope_relative(sup, scope)
|
|
412
|
+
unless parent
|
|
413
|
+
raise UnresolvableSetup.new(
|
|
414
|
+
:opaque_constructor,
|
|
415
|
+
"#{scope.name} inherits from #{sup}, which is not defined in " \
|
|
416
|
+
"#{@file_path}, and defines no #initialize of its own, so its " \
|
|
417
|
+
"constructor signature can't be read statically. Give #{scope.name} " \
|
|
418
|
+
"an explicit #initialize, or pin a class-method entry point."
|
|
419
|
+
)
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
parent_init = find_initialize(parent.name)
|
|
423
|
+
return [:new, params_of(parent_init)] if parent_init
|
|
424
|
+
|
|
425
|
+
parent_sup = parent.superclass_slice
|
|
426
|
+
return [:new, []] if parent_sup.nil? || INERT_BASES.include?(parent_sup)
|
|
427
|
+
|
|
428
|
+
raise UnresolvableSetup.new(
|
|
429
|
+
:opaque_constructor,
|
|
430
|
+
"#{scope.name} < #{parent.name} < #{parent_sup}: neither #{scope.name} " \
|
|
431
|
+
"nor #{parent.name} defines #initialize, and #{parent_sup} is outside " \
|
|
432
|
+
"#{@file_path}. pinspec resolves one level up only."
|
|
433
|
+
)
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
def guard_opaque_initialize!(scope, init)
|
|
437
|
+
super_node = find_within(init.body, Prism::SuperNode)
|
|
438
|
+
if super_node && super_node.arguments && !super_node.arguments.arguments.empty?
|
|
439
|
+
sup = scope.superclass_slice
|
|
440
|
+
parent = sup && resolve_scope_relative(sup, scope)
|
|
441
|
+
|
|
442
|
+
unless parent
|
|
443
|
+
raise UnresolvableSetup.new(
|
|
444
|
+
:opaque_constructor,
|
|
445
|
+
"#{scope.name}#initialize calls `super(...)` (line " \
|
|
446
|
+
"#{super_node.location.start_line}) and its superclass " \
|
|
447
|
+
"#{sup || '(none detected)'} is not defined in #{@file_path}, so the " \
|
|
448
|
+
"effective constructor can't be resolved one level up."
|
|
449
|
+
)
|
|
450
|
+
end
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
di = find_di_dependency(init.body)
|
|
454
|
+
return unless di
|
|
455
|
+
|
|
456
|
+
raise UnresolvableSetup.new(
|
|
457
|
+
:opaque_constructor,
|
|
458
|
+
"#{scope.name}#initialize resolves a dependency from `#{di[:source]}` " \
|
|
459
|
+
"(line #{di[:line]}) instead of taking it as an argument, so pinspec " \
|
|
460
|
+
"can't control it. Inject it as a parameter; a default value is fine, " \
|
|
461
|
+
"pinspec overrides defaults."
|
|
462
|
+
)
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
def find_di_dependency(body)
|
|
466
|
+
found = nil
|
|
467
|
+
|
|
468
|
+
walk_within(body) do |node|
|
|
469
|
+
next unless node.is_a?(Prism::CallNode)
|
|
470
|
+
next if found
|
|
471
|
+
|
|
472
|
+
text = node.slice
|
|
473
|
+
next unless DI_PATTERNS.any? { |re| text.match?(re) }
|
|
474
|
+
|
|
475
|
+
found = { source: text.lines.first.strip, line: node.location.start_line }
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
found
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
def model_scope?(scope)
|
|
482
|
+
sup = scope.superclass_slice
|
|
483
|
+
return false unless sup
|
|
484
|
+
|
|
485
|
+
MODEL_BASES.include?(sup) || MODEL_BASES.any? { |b| sup.end_with?("::#{b}") }
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
def struct_members(scope)
|
|
489
|
+
node = scope.superclass_node
|
|
490
|
+
return nil unless node.is_a?(Prism::CallNode)
|
|
491
|
+
return nil unless %w[Struct Data].include?(node.receiver&.slice)
|
|
492
|
+
return nil unless %i[new define].include?(node.name)
|
|
493
|
+
|
|
494
|
+
Array(node.arguments&.arguments).grep(Prism::SymbolNode).map do |sym|
|
|
495
|
+
build_param(sym.unescaped.to_sym, :req, nil)
|
|
496
|
+
end
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
def interactor?(scope)
|
|
500
|
+
scope_calls(scope).any? do |call|
|
|
501
|
+
call.name == :include &&
|
|
502
|
+
Array(call.arguments&.arguments).any? { |a| a.slice.split("::").last == "Interactor" }
|
|
503
|
+
end
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
def interactor_params(scope)
|
|
507
|
+
@delegations
|
|
508
|
+
.select { |d| d.scope_name == scope.name && d.to.to_s == "context" }
|
|
509
|
+
.map { |d| build_param(d.method, :key, nil) }
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
def dry_initializer?(scope)
|
|
513
|
+
scope_calls(scope).any? do |call|
|
|
514
|
+
%i[extend include].include?(call.name) &&
|
|
515
|
+
Array(call.arguments&.arguments).any? { |a| a.slice.include?("Dry::Initializer") }
|
|
516
|
+
end
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
def dry_params(scope)
|
|
520
|
+
scope_calls(scope).filter_map do |call|
|
|
521
|
+
next unless %i[param option].include?(call.name)
|
|
522
|
+
|
|
523
|
+
args = Array(call.arguments&.arguments)
|
|
524
|
+
sym = args.grep(Prism::SymbolNode).first
|
|
525
|
+
next unless sym
|
|
526
|
+
|
|
527
|
+
default = dry_default(args)
|
|
528
|
+
|
|
529
|
+
if call.name == :param
|
|
530
|
+
build_param(sym.unescaped.to_sym, default ? :opt : :req, default)
|
|
531
|
+
else
|
|
532
|
+
build_param(sym.unescaped.to_sym, default ? :key : :keyreq, default)
|
|
533
|
+
end
|
|
534
|
+
end
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
def dry_default(args)
|
|
538
|
+
hash = args.grep(Prism::KeywordHashNode).first
|
|
539
|
+
return nil unless hash
|
|
540
|
+
|
|
541
|
+
assoc = hash.elements.grep(Prism::AssocNode).find do |a|
|
|
542
|
+
a.key.is_a?(Prism::SymbolNode) && a.key.unescaped == "default"
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
assoc&.value&.slice
|
|
546
|
+
end
|
|
547
|
+
|
|
548
|
+
def scope_calls(scope)
|
|
549
|
+
Array(scope.node.body&.body).grep(Prism::CallNode)
|
|
550
|
+
end
|
|
551
|
+
|
|
552
|
+
def find_initialize(scope_name)
|
|
553
|
+
@defs.find do |d|
|
|
554
|
+
d.scope_name == scope_name && d.name == :initialize && !d.singleton
|
|
555
|
+
end&.node
|
|
556
|
+
end
|
|
557
|
+
|
|
558
|
+
def resolve_scope_relative(name, scope)
|
|
559
|
+
nesting = scope.name.split("::")
|
|
560
|
+
|
|
561
|
+
while nesting.any?
|
|
562
|
+
nesting.pop
|
|
563
|
+
candidate = (nesting + [name]).join("::")
|
|
564
|
+
return @scopes[candidate] if @scopes.key?(candidate)
|
|
565
|
+
end
|
|
566
|
+
|
|
567
|
+
@scopes[name]
|
|
568
|
+
end
|
|
569
|
+
|
|
570
|
+
def params_of(def_node)
|
|
571
|
+
parameters = def_node.parameters
|
|
572
|
+
return [] unless parameters
|
|
573
|
+
|
|
574
|
+
out = []
|
|
575
|
+
|
|
576
|
+
parameters.requireds.each do |node|
|
|
577
|
+
out << build_param(param_name(node), :req, nil)
|
|
578
|
+
end
|
|
579
|
+
|
|
580
|
+
parameters.optionals.each do |node|
|
|
581
|
+
out << build_param(param_name(node), :opt, node.value&.slice)
|
|
582
|
+
end
|
|
583
|
+
|
|
584
|
+
if parameters.rest && parameters.rest.is_a?(Prism::RestParameterNode)
|
|
585
|
+
out << build_param(parameters.rest.name || :args, :rest, nil)
|
|
586
|
+
end
|
|
587
|
+
|
|
588
|
+
parameters.posts.each do |node|
|
|
589
|
+
out << build_param(param_name(node), :req, nil)
|
|
590
|
+
end
|
|
591
|
+
|
|
592
|
+
parameters.keywords.each do |node|
|
|
593
|
+
if node.respond_to?(:value) && node.value
|
|
594
|
+
out << build_param(node.name, :key, node.value.slice)
|
|
595
|
+
else
|
|
596
|
+
out << build_param(node.name, :keyreq, nil)
|
|
597
|
+
end
|
|
598
|
+
end
|
|
599
|
+
|
|
600
|
+
if parameters.keyword_rest.is_a?(Prism::KeywordRestParameterNode)
|
|
601
|
+
out << build_param(parameters.keyword_rest.name || :options, :keyrest, nil)
|
|
602
|
+
end
|
|
603
|
+
|
|
604
|
+
out
|
|
605
|
+
end
|
|
606
|
+
|
|
607
|
+
def param_name(node)
|
|
608
|
+
node.respond_to?(:name) ? node.name : node.slice.to_sym
|
|
609
|
+
end
|
|
610
|
+
|
|
611
|
+
def build_param(name, kind, default_source)
|
|
612
|
+
Param.new(
|
|
613
|
+
name: name,
|
|
614
|
+
kind: kind,
|
|
615
|
+
default_source: default_source,
|
|
616
|
+
type_hint: type_hint_for(name, kind, default_source)
|
|
617
|
+
)
|
|
618
|
+
end
|
|
619
|
+
|
|
620
|
+
def type_hint_for(name, kind, default_source = nil)
|
|
621
|
+
return nil if %i[rest keyrest].include?(kind)
|
|
622
|
+
|
|
623
|
+
from_default = hint_from_default(default_source)
|
|
624
|
+
return from_default if from_default
|
|
625
|
+
|
|
626
|
+
s = name.to_s
|
|
627
|
+
return "Boolean" if s.end_with?("?") || s.start_with?("is_", "has_")
|
|
628
|
+
return "Integer" if s.end_with?("_id", "_count", "_index", "_num")
|
|
629
|
+
return "Array" if s.end_with?("_ids")
|
|
630
|
+
return "Time" if s.end_with?("_at")
|
|
631
|
+
return "Date" if s.end_with?("_on", "_date")
|
|
632
|
+
return nil if SCALARISH_NAMES.include?(s)
|
|
633
|
+
return nil unless s.match?(/\A[a-z][a-z0-9_]*\z/)
|
|
634
|
+
|
|
635
|
+
camelize(s)
|
|
636
|
+
end
|
|
637
|
+
|
|
638
|
+
def hint_from_default(source)
|
|
639
|
+
return nil if source.nil?
|
|
640
|
+
|
|
641
|
+
case source.strip
|
|
642
|
+
when "true", "false" then "Boolean"
|
|
643
|
+
when /\A:[A-Za-z_]/ then "Symbol"
|
|
644
|
+
when /\A-?\d+\z/ then "Integer"
|
|
645
|
+
when /\A-?\d+\.\d+\z/ then "Float"
|
|
646
|
+
when /\A["']/ then "String"
|
|
647
|
+
when /\A(\[|%[wi]?[\[(])/ then "Array"
|
|
648
|
+
when /\A\{/ then "Hash"
|
|
649
|
+
when /\A(->|lambda|proc)\b/ then "Proc"
|
|
650
|
+
when /\A([A-Z][\w:]*)\.new\b/ then Regexp.last_match(1)
|
|
651
|
+
end
|
|
652
|
+
end
|
|
653
|
+
|
|
654
|
+
def referenced_constants(nodes)
|
|
655
|
+
found = []
|
|
656
|
+
|
|
657
|
+
nodes.each do |root|
|
|
658
|
+
walk_within(root) do |node|
|
|
659
|
+
case node
|
|
660
|
+
when Prism::ConstantPathNode then found << node.slice
|
|
661
|
+
when Prism::ConstantReadNode then found << node.name.to_s
|
|
662
|
+
end
|
|
663
|
+
end
|
|
664
|
+
end
|
|
665
|
+
|
|
666
|
+
roots = found.grep(/::/).map { |c| c.split("::").first }
|
|
667
|
+
found.uniq.reject { |c| !c.include?("::") && roots.include?(c) }.sort
|
|
668
|
+
end
|
|
669
|
+
|
|
670
|
+
def clock_sites(nodes)
|
|
671
|
+
sites = []
|
|
672
|
+
|
|
673
|
+
nodes.each do |root|
|
|
674
|
+
walk_within(root) do |node|
|
|
675
|
+
next unless node.is_a?(Prism::CallNode)
|
|
676
|
+
|
|
677
|
+
receiver = node.receiver
|
|
678
|
+
next unless receiver.is_a?(Prism::ConstantReadNode)
|
|
679
|
+
|
|
680
|
+
allowed = CLOCK_CALLS[receiver.name.to_s]
|
|
681
|
+
next unless allowed&.include?(node.name)
|
|
682
|
+
|
|
683
|
+
next if node.arguments && !node.arguments.arguments.empty?
|
|
684
|
+
|
|
685
|
+
sites << ClockSite.new(
|
|
686
|
+
call: "#{receiver.name}.#{node.name}",
|
|
687
|
+
line: node.location.start_line
|
|
688
|
+
)
|
|
689
|
+
end
|
|
690
|
+
end
|
|
691
|
+
|
|
692
|
+
sites.uniq { |s| [s.call, s.line] }.sort_by(&:line)
|
|
693
|
+
end
|
|
694
|
+
|
|
695
|
+
def find_within(body, klass)
|
|
696
|
+
result = nil
|
|
697
|
+
walk_within(body) { |node| result ||= node if node.is_a?(klass) }
|
|
698
|
+
result
|
|
699
|
+
end
|
|
700
|
+
|
|
701
|
+
def walk_within(node, root: true, &block)
|
|
702
|
+
return unless node
|
|
703
|
+
return if !root && PRUNE_AT.any? { |k| node.is_a?(k) }
|
|
704
|
+
|
|
705
|
+
block.call(node)
|
|
706
|
+
node.compact_child_nodes.each { |child| walk_within(child, root: false, &block) }
|
|
707
|
+
end
|
|
708
|
+
end
|
|
709
|
+
end
|
|
710
|
+
end
|