pinspec 0.1.0 → 0.3.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.
@@ -19,13 +19,26 @@ module Pinspec
19
19
 
20
20
  VISIBILITIES = %i[public private protected].freeze
21
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
22
+ # Names that are a bag of attributes rather than a model. `create_params`,
23
+ # `options`, `article_attributes`.
24
+ HASH_SHAPED = /\A(?:\w+_)?(?:params|options|attributes|attrs|opts)\z/
25
+
26
+ # These names are not models - but saying only that leaves the corpus with no
27
+ # value at all, and a required parameter then receives an invented nil. The
28
+ # target raises on it and pinspec pins that error as the application's
29
+ # behaviour, which is the one thing it promises never to do. So each name maps
30
+ # to a type the corpus can actually build.
31
+ SCALARISH_TYPES = {
32
+ "String" => %w[
33
+ name email phone title body text message description reason code key
34
+ path url slug token label format mode scope type kind status state
35
+ value data
36
+ ],
37
+ "Integer" => %w[count index number num limit offset size length quantity qty position],
38
+ "Float" => %w[amount total subtotal price cost percent rate ratio sum]
39
+ }.freeze
40
+
41
+ SCALARISH_NAMES = SCALARISH_TYPES.values.flatten.freeze
29
42
 
30
43
  DI_PATTERNS = [
31
44
  /\ARails\.application\.config\b/,
@@ -60,18 +73,48 @@ module Pinspec
60
73
  new(file_path, method_name).parse
61
74
  end
62
75
 
76
+ # Returns [file, method] with method nil when the target named none. It is
77
+ # the caller's job to discover one, because that needs the file's contents
78
+ # and the surrounding directory's convention.
63
79
  def split_target(target)
64
- unless target.to_s.include?("#")
80
+ text = target.to_s
81
+ return text.split("#", 2) if text.include?("#")
82
+
83
+ unless text.end_with?(".rb")
65
84
  raise ArgumentError,
66
- "target must be FILE#METHOD (got #{target.inspect}); " \
67
- "e.g. app/services/invoice_calculator.rb#call"
85
+ "target must be a Ruby file, optionally with #METHOD " \
86
+ "(got #{target.inspect}); e.g. app/services/invoice_calculator.rb#call"
68
87
  end
69
88
 
70
- target.split("#", 2)
89
+ [text, nil]
71
90
  end
72
91
  end
73
92
 
74
- def initialize(file_path, method_name)
93
+ class << self
94
+ # Keyed by application root, because a batch run builds one parser per file
95
+ # and re-globbing the application for each would be quadratic.
96
+ def application_sources_for(root)
97
+ @application_sources ||= {}
98
+ @application_sources[root] ||= begin
99
+ dirs = %w[app lib].map { |d| File.join(root, d) }.select { |d| File.directory?(d) }
100
+ dirs.flat_map { |dir| Dir.glob(File.join(dir, "**", "*.rb")) }.sort
101
+ end
102
+ end
103
+
104
+ def application_scope_cache(root)
105
+ @application_scopes ||= {}
106
+ @application_scopes[root] ||= {}
107
+ end
108
+
109
+ # Tests and long-lived processes need a way back to a clean slate.
110
+ def reset_application_cache!
111
+ @application_sources = {}
112
+ @application_scopes = {}
113
+ end
114
+ end
115
+
116
+ def initialize(file_path, method_name, app_root: nil)
117
+ @app_root = app_root
75
118
  @file_path = file_path
76
119
  @descriptor = parse_descriptor(method_name)
77
120
  end
@@ -108,7 +151,8 @@ module Pinspec
108
151
  takes_block: false,
109
152
  source_range: [mdef.node.location.start_line, mdef.node.location.end_line],
110
153
  referenced_constants: referenced_constants(scan),
111
- clock_sites: clock_sites(scan)
154
+ clock_sites: clock_sites(scan),
155
+ construction_source: @construction_source || :own
112
156
  )
113
157
  end
114
158
 
@@ -387,8 +431,12 @@ module Pinspec
387
431
 
388
432
  def resolve_construction(scope, mdef)
389
433
  return [:class_method, []] if mdef.singleton
434
+ return [:class_method, []] if module_self_calling?(scope)
390
435
  return [:model_instance, []] if model_scope?(scope)
391
436
 
437
+ attr_extras = attr_extras_params(scope)
438
+ return [:new, attr_extras] if attr_extras
439
+
392
440
  members = struct_members(scope)
393
441
  return [:struct, members] if members
394
442
 
@@ -404,33 +452,60 @@ module Pinspec
404
452
  inherited_construction(scope)
405
453
  end
406
454
 
455
+ # A class with no #initialize is not an unreadable constructor - it is the
456
+ # default one. The commonest shape in real applications is a service that
457
+ # inherits behaviour and takes its dependencies as method arguments:
458
+ #
459
+ # class FavouriteService < BaseService
460
+ # def call(account, status)
461
+ #
462
+ # Refusing that cost 88% of one real codebase's service directory. So the
463
+ # superclass chain is followed through the application, and only a constructor
464
+ # that EXISTS and cannot be read is refused.
465
+ MAX_ANCESTOR_HOPS = 4
466
+
407
467
  def inherited_construction(scope)
408
468
  sup = scope.superclass_slice
409
469
  return [:new, []] if sup.nil? || INERT_BASES.include?(sup)
410
470
 
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
471
+ params = ancestor_initializer_params(sup, scope)
472
+ return params if params
421
473
 
422
- parent_init = find_initialize(parent.name)
423
- return [:new, params_of(parent_init)] if parent_init
474
+ # Nothing in the chain defines one, so `new` takes no arguments. When the
475
+ # chain left the application the assumption is unverified, and says so.
476
+ @construction_source = @left_application ? :assumed : :inherited
477
+ [:new, []]
478
+ end
424
479
 
425
- parent_sup = parent.superclass_slice
426
- return [:new, []] if parent_sup.nil? || INERT_BASES.include?(parent_sup)
480
+ # Walks up through the application. Returns [:new, params] when an ancestor
481
+ # defines #initialize, or nil when none does.
482
+ def ancestor_initializer_params(name, scope)
483
+ seen = []
484
+ current = name
485
+ hops = 0
427
486
 
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
- )
487
+ while current && hops < MAX_ANCESTOR_HOPS
488
+ break if INERT_BASES.include?(current) || seen.include?(current)
489
+
490
+ seen << current
491
+ hops += 1
492
+
493
+ parent = resolve_scope_relative(current, scope) || scope_from_application(current)
494
+ if parent.nil?
495
+ @left_application = true
496
+ return nil
497
+ end
498
+
499
+ init = find_initialize(parent.name) || initializer_in(parent)
500
+ if init
501
+ @construction_source = :inherited
502
+ return [:new, params_of(init)]
503
+ end
504
+
505
+ current = parent.superclass_slice
506
+ end
507
+
508
+ nil
434
509
  end
435
510
 
436
511
  def guard_opaque_initialize!(scope, init)
@@ -485,6 +560,62 @@ module Pinspec
485
560
  MODEL_BASES.include?(sup) || MODEL_BASES.any? { |b| sup.end_with?("::#{b}") }
486
561
  end
487
562
 
563
+ # A module that calls `module_function` or `extend self` answers its own methods,
564
+ # so there is nothing to construct. Calling .new on it raised
565
+ # "undefined method 'new' for module ..." inside the probe - a setup_error that
566
+ # named the application rather than the shape pinspec had failed to recognise.
567
+ def module_self_calling?(scope)
568
+ return false unless scope.kind == :module
569
+
570
+ scope_calls(scope).any? do |call|
571
+ call.receiver.nil? && %i[module_function extend].include?(call.name) &&
572
+ (call.name == :module_function || self_argument?(call))
573
+ end
574
+ end
575
+
576
+ def self_argument?(call)
577
+ Array(call.arguments&.arguments).any? { |arg| arg.is_a?(Prism::SelfNode) }
578
+ end
579
+
580
+ # attr_extras. `pattr_initialize`/`attr_initialize` generate the constructor, so
581
+ # a class using one has no `def initialize` to read and looked like it took no
582
+ # arguments at all - pinspec then called .new with nothing and the generated
583
+ # constructor raised KeyError. That is 110 of chatwoot's 386 service files.
584
+ #
585
+ # pattr_initialize :account, :params -> two required positionals
586
+ # pattr_initialize [:inbox!, :params!] -> two required keywords
587
+ # pattr_initialize [:channel!, :message] -> one required, one optional
588
+ #
589
+ # A bare symbol is positional; symbols inside an array are keywords, and the
590
+ # trailing "!" is attr_extras' own mark for "required".
591
+ ATTR_EXTRAS_MACROS = %i[pattr_initialize attr_initialize].freeze
592
+
593
+ def attr_extras_params(scope)
594
+ call = scope_calls(scope).find do |node|
595
+ node.receiver.nil? && ATTR_EXTRAS_MACROS.include?(node.name)
596
+ end
597
+ return nil if call.nil?
598
+
599
+ Array(call.arguments&.arguments).flat_map { |arg| attr_extras_args(arg) }
600
+ end
601
+
602
+ def attr_extras_args(node)
603
+ case node
604
+ when Prism::SymbolNode
605
+ [build_param(node.unescaped.to_sym, :req, nil)]
606
+ when Prism::ArrayNode
607
+ node.elements.grep(Prism::SymbolNode).map do |element|
608
+ name = element.unescaped
609
+ required = name.end_with?("!")
610
+
611
+ build_param(name.delete_suffix("!").to_sym, required ? :keyreq : :key,
612
+ required ? nil : "nil")
613
+ end
614
+ else
615
+ []
616
+ end
617
+ end
618
+
488
619
  def struct_members(scope)
489
620
  node = scope.superclass_node
490
621
  return nil unless node.is_a?(Prism::CallNode)
@@ -555,6 +686,110 @@ module Pinspec
555
686
  end&.node
556
687
  end
557
688
 
689
+ # The superclass usually lives in another file of the same application. Finding
690
+ # it is a search for `class <Name>` under app/ and lib/, parsed with Prism so a
691
+ # name inside a comment or a string cannot match.
692
+ def scope_from_application(name)
693
+ return nil if application_root.nil?
694
+
695
+ cache = self.class.application_scope_cache(application_root)
696
+ return cache[name] if cache.key?(name)
697
+
698
+ cache[name] = find_scope_in_application(name)
699
+ end
700
+
701
+ def find_scope_in_application(name)
702
+ demodulized = name.split("::").last
703
+ pattern = /^\s*class\s+(?:[\w:]+::)?#{Regexp.escape(demodulized)}\b/
704
+
705
+ application_sources.each do |path|
706
+ source = Source.read(path)
707
+ next unless source.match?(pattern)
708
+
709
+ result = Prism.parse(source)
710
+ next unless result.success?
711
+
712
+ found = scope_named(result.value, name, demodulized)
713
+ return found if found
714
+ end
715
+
716
+ nil
717
+ end
718
+
719
+ # Only the files an application owns. A gem's base class is deliberately out of
720
+ # reach: pinspec would be reading code the application cannot change.
721
+ def application_sources
722
+ self.class.application_sources_for(application_root)
723
+ end
724
+
725
+ def application_root
726
+ return @application_root if defined?(@application_root)
727
+
728
+ @application_root = @app_root || infer_application_root
729
+ end
730
+
731
+ # Walk up from the target until something that marks a Rails root appears.
732
+ def infer_application_root
733
+ dir = File.dirname(File.expand_path(@file_path))
734
+
735
+ while dir != "/" && dir != File.dirname(dir)
736
+ return dir if File.file?(File.join(dir, "Gemfile")) ||
737
+ File.directory?(File.join(dir, "db")) ||
738
+ File.file?(File.join(dir, "config", "application.rb"))
739
+
740
+ dir = File.dirname(dir)
741
+ end
742
+
743
+ nil
744
+ end
745
+
746
+ def scope_named(root, qualified, demodulized)
747
+ found = nil
748
+
749
+ walk = lambda do |node, prefix|
750
+ return if found || node.nil?
751
+
752
+ if node.is_a?(Prism::ClassNode)
753
+ name = [prefix, node.constant_path.slice].compact.reject(&:empty?).join("::")
754
+ if name == qualified || node.constant_path.slice == qualified ||
755
+ node.constant_path.slice == demodulized
756
+ found = Scope.new(name: name, node: node, kind: :class,
757
+ superclass_slice: node.superclass&.slice,
758
+ superclass_node: node.superclass, parent: nil)
759
+ return
760
+ end
761
+ prefix = name
762
+ elsif node.is_a?(Prism::ModuleNode)
763
+ prefix = [prefix, node.constant_path.slice].compact.reject(&:empty?).join("::")
764
+ end
765
+
766
+ node.compact_child_nodes.each { |child| walk.call(child, prefix) }
767
+ end
768
+
769
+ walk.call(root, nil)
770
+ found
771
+ end
772
+
773
+ # An #initialize defined directly in a scope found elsewhere in the application.
774
+ def initializer_in(scope)
775
+ return nil unless scope.node.is_a?(Prism::ClassNode)
776
+
777
+ found = nil
778
+ walk = lambda do |node|
779
+ return if found || node.nil?
780
+
781
+ if node.is_a?(Prism::DefNode) && node.name == :initialize && node.receiver.nil?
782
+ found = node
783
+ return
784
+ end
785
+
786
+ node.compact_child_nodes.each { |child| walk.call(child) }
787
+ end
788
+
789
+ walk.call(scope.node.body)
790
+ found
791
+ end
792
+
558
793
  def resolve_scope_relative(name, scope)
559
794
  nesting = scope.name.split("::")
560
795
 
@@ -629,7 +864,10 @@ module Pinspec
629
864
  return "Array" if s.end_with?("_ids")
630
865
  return "Time" if s.end_with?("_at")
631
866
  return "Date" if s.end_with?("_on", "_date")
632
- return nil if SCALARISH_NAMES.include?(s)
867
+ return "Hash" if HASH_SHAPED.match?(s)
868
+ return "Array" if s.end_with?("s") && !s.end_with?("ss", "us", "is")
869
+ scalar = SCALARISH_TYPES.find { |_type, names| names.include?(s) }
870
+ return scalar.first if scalar
633
871
  return nil unless s.match?(/\A[a-z][a-z0-9_]*\z/)
634
872
 
635
873
  camelize(s)
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pinspec
4
+ class Batch
5
+ SKIP_PATTERNS = [%r{/concerns/}, %r{_spec\.rb\z}, %r{/spec/}, %r{/test/}].freeze
6
+
7
+ Outcome = Data.define(:file, :target, :status, :detail, :pinned, :spec_path) do
8
+ def pinned?
9
+ status == :pinned
10
+ end
11
+
12
+ def refused?
13
+ status == :refused
14
+ end
15
+ end
16
+
17
+ Report = Data.define(:outcomes) do
18
+ def pinned
19
+ outcomes.select(&:pinned?)
20
+ end
21
+
22
+ def refused
23
+ outcomes.select(&:refused?)
24
+ end
25
+
26
+ def failed
27
+ outcomes.reject { |outcome| outcome.pinned? || outcome.refused? }
28
+ end
29
+
30
+ def anything_pinned?
31
+ !pinned.empty?
32
+ end
33
+ end
34
+
35
+ # Patterns match the path RELATIVE to the directory being pinned. Matching the
36
+ # absolute path means an app that happens to live under a directory called
37
+ # `spec` has every one of its files skipped.
38
+ def self.targets_in(path)
39
+ return [path] if File.file?(path)
40
+
41
+ root = File.expand_path(path)
42
+
43
+ Dir.glob(File.join(root, "**", "*.rb")).reject do |file|
44
+ relative = file.delete_prefix("#{root}/")
45
+
46
+ SKIP_PATTERNS.any? { |pattern| "/#{relative}".match?(pattern) }
47
+ end.sort
48
+ end
49
+
50
+ def initialize(files, &pin)
51
+ @files = files
52
+ @pin = pin
53
+ end
54
+
55
+ # A refusal is information, not a stop: one target that takes a block must not
56
+ # end a run over a directory of forty.
57
+ def run
58
+ Report.new(outcomes: @files.map { |file| attempt(file) })
59
+ end
60
+
61
+ private
62
+
63
+ def attempt(file)
64
+ @pin.call(file)
65
+ rescue UnresolvableSetup, BlockRequired, AmbiguousTarget, TargetNotFound,
66
+ NothingStableToPin, UnsupportedRailsVersion => e
67
+ Outcome.new(file: file, target: nil, status: :refused, detail: reason_for(e),
68
+ pinned: 0, spec_path: nil)
69
+ rescue VerifyFailed, ProbeFailure => e
70
+ Outcome.new(file: file, target: nil, status: :failed, detail: e.message.lines.first.to_s.strip,
71
+ pinned: 0, spec_path: nil)
72
+ end
73
+
74
+ def reason_for(error)
75
+ return "#{error.class.name.split('::').last}(#{error.reason})" if error.respond_to?(:reason)
76
+
77
+ error.class.name.split("::").last
78
+ end
79
+ end
80
+ end