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.
Files changed (39) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +133 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +183 -0
  5. data/exe/pinspec +8 -0
  6. data/lib/pinspec/analyzer/app_profile_reader.rb +348 -0
  7. data/lib/pinspec/analyzer/factory_registry.rb +288 -0
  8. data/lib/pinspec/analyzer/inflector.rb +85 -0
  9. data/lib/pinspec/analyzer/schema_reader.rb +444 -0
  10. data/lib/pinspec/analyzer/source.rb +56 -0
  11. data/lib/pinspec/analyzer/target_parser.rb +710 -0
  12. data/lib/pinspec/cli.rb +585 -0
  13. data/lib/pinspec/emit/namer.rb +103 -0
  14. data/lib/pinspec/emit/spec_writer.rb +504 -0
  15. data/lib/pinspec/emit/stability_filter.rb +183 -0
  16. data/lib/pinspec/errors.rb +85 -0
  17. data/lib/pinspec/inputs/boundary.rb +112 -0
  18. data/lib/pinspec/inputs/corpus.rb +148 -0
  19. data/lib/pinspec/inputs/hydrator.rb +197 -0
  20. data/lib/pinspec/inputs/redactor.rb +138 -0
  21. data/lib/pinspec/inputs/sample_runner.rb +98 -0
  22. data/lib/pinspec/inputs/sampler.rb +187 -0
  23. data/lib/pinspec/report/summary.rb +348 -0
  24. data/lib/pinspec/runner/capture.rb +127 -0
  25. data/lib/pinspec/runner/probe_generator.rb +662 -0
  26. data/lib/pinspec/runner/sandbox.rb +121 -0
  27. data/lib/pinspec/setup/context_builder.rb +471 -0
  28. data/lib/pinspec/setup/dependency_resolver.rb +236 -0
  29. data/lib/pinspec/tags.rb +103 -0
  30. data/lib/pinspec/types.rb +497 -0
  31. data/lib/pinspec/validate/mutation_adapter.rb +108 -0
  32. data/lib/pinspec/validate/pin_scorer.rb +164 -0
  33. data/lib/pinspec/verify/verifier.rb +149 -0
  34. data/lib/pinspec/version.rb +8 -0
  35. data/lib/pinspec.rb +17 -0
  36. data/templates/factory_build.rb +54 -0
  37. data/templates/serializer.rb +243 -0
  38. data/templates/spec_support.rb +83 -0
  39. 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