pinspec 0.2.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,8 +73,9 @@ module Pinspec
60
73
  new(file_path, method_name).parse
61
74
  end
62
75
 
63
- DEFAULT_METHOD = "call"
64
-
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.
65
79
  def split_target(target)
66
80
  text = target.to_s
67
81
  return text.split("#", 2) if text.include?("#")
@@ -72,11 +86,35 @@ module Pinspec
72
86
  "(got #{target.inspect}); e.g. app/services/invoice_calculator.rb#call"
73
87
  end
74
88
 
75
- [text, DEFAULT_METHOD]
89
+ [text, nil]
76
90
  end
77
91
  end
78
92
 
79
- 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
80
118
  @file_path = file_path
81
119
  @descriptor = parse_descriptor(method_name)
82
120
  end
@@ -113,7 +151,8 @@ module Pinspec
113
151
  takes_block: false,
114
152
  source_range: [mdef.node.location.start_line, mdef.node.location.end_line],
115
153
  referenced_constants: referenced_constants(scan),
116
- clock_sites: clock_sites(scan)
154
+ clock_sites: clock_sites(scan),
155
+ construction_source: @construction_source || :own
117
156
  )
118
157
  end
119
158
 
@@ -392,8 +431,12 @@ module Pinspec
392
431
 
393
432
  def resolve_construction(scope, mdef)
394
433
  return [:class_method, []] if mdef.singleton
434
+ return [:class_method, []] if module_self_calling?(scope)
395
435
  return [:model_instance, []] if model_scope?(scope)
396
436
 
437
+ attr_extras = attr_extras_params(scope)
438
+ return [:new, attr_extras] if attr_extras
439
+
397
440
  members = struct_members(scope)
398
441
  return [:struct, members] if members
399
442
 
@@ -409,33 +452,60 @@ module Pinspec
409
452
  inherited_construction(scope)
410
453
  end
411
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
+
412
467
  def inherited_construction(scope)
413
468
  sup = scope.superclass_slice
414
469
  return [:new, []] if sup.nil? || INERT_BASES.include?(sup)
415
470
 
416
- parent = resolve_scope_relative(sup, scope)
417
- unless parent
418
- raise UnresolvableSetup.new(
419
- :opaque_constructor,
420
- "#{scope.name} inherits from #{sup}, which is not defined in " \
421
- "#{@file_path}, and defines no #initialize of its own, so its " \
422
- "constructor signature can't be read statically. Give #{scope.name} " \
423
- "an explicit #initialize, or pin a class-method entry point."
424
- )
425
- end
471
+ params = ancestor_initializer_params(sup, scope)
472
+ return params if params
426
473
 
427
- parent_init = find_initialize(parent.name)
428
- 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
429
479
 
430
- parent_sup = parent.superclass_slice
431
- 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
432
486
 
433
- raise UnresolvableSetup.new(
434
- :opaque_constructor,
435
- "#{scope.name} < #{parent.name} < #{parent_sup}: neither #{scope.name} " \
436
- "nor #{parent.name} defines #initialize, and #{parent_sup} is outside " \
437
- "#{@file_path}. pinspec resolves one level up only."
438
- )
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
439
509
  end
440
510
 
441
511
  def guard_opaque_initialize!(scope, init)
@@ -490,6 +560,62 @@ module Pinspec
490
560
  MODEL_BASES.include?(sup) || MODEL_BASES.any? { |b| sup.end_with?("::#{b}") }
491
561
  end
492
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
+
493
619
  def struct_members(scope)
494
620
  node = scope.superclass_node
495
621
  return nil unless node.is_a?(Prism::CallNode)
@@ -560,6 +686,110 @@ module Pinspec
560
686
  end&.node
561
687
  end
562
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
+
563
793
  def resolve_scope_relative(name, scope)
564
794
  nesting = scope.name.split("::")
565
795
 
@@ -634,7 +864,10 @@ module Pinspec
634
864
  return "Array" if s.end_with?("_ids")
635
865
  return "Time" if s.end_with?("_at")
636
866
  return "Date" if s.end_with?("_on", "_date")
637
- 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
638
871
  return nil unless s.match?(/\A[a-z][a-z0-9_]*\z/)
639
872
 
640
873
  camelize(s)
data/lib/pinspec/cli.rb CHANGED
@@ -11,7 +11,7 @@ module Pinspec
11
11
  true
12
12
  end
13
13
 
14
- ORDER = %w[pin init analyze validate report plan capture version].freeze
14
+ ORDER = %w[pin verify init analyze validate report plan capture version].freeze
15
15
 
16
16
  # Thor sorts the command list alphabetically after building it, which buries the
17
17
  # one verb most people want between `init` and `plan`, and gives the two
@@ -30,6 +30,32 @@ module Pinspec
30
30
  end
31
31
  map %w[--version -v] => :version
32
32
 
33
+ desc "verify SPEC_FILE", "Run any spec file in pinspec's environments, whoever wrote it"
34
+ method_option :app, type: :string, default: ".", desc: "target app root"
35
+ method_option :"verify-level", type: :string, default: "full", enum: %w[full isolated]
36
+ method_option :"app-env", type: :string, repeatable: true, banner: "KEY=VALUE",
37
+ desc: "environment for the app's own runtime"
38
+ def verify(*args)
39
+ guarded do
40
+ spec_file = target_from(args)
41
+ path = File.expand_path(spec_file, options[:app])
42
+
43
+ unless File.file?(path)
44
+ raise TargetNotFound, "no spec file at #{path}"
45
+ end
46
+
47
+ outcomes = Verify::Verifier.new(
48
+ app_root: options[:app], spec_path: path, env: app_env,
49
+ level: setting("verify-level", "full").to_sym
50
+ ).verify
51
+
52
+ puts "verify #{spec_file}"
53
+ print_verification(outcomes)
54
+
55
+ raise VerifyFailed, verify_failed_message(outcomes) unless outcomes.all?(&:green?)
56
+ end
57
+ end
58
+
33
59
  desc "init [APP_PATH]", "Write .pinspec.yml so later runs need no flags"
34
60
  method_option :force, type: :boolean, default: false, desc: "overwrite an existing .pinspec.yml"
35
61
  def init(app_path = ".")
@@ -71,9 +97,11 @@ module Pinspec
71
97
  method_option :app, type: :string, default: ".", desc: "target app root"
72
98
  method_option :cases, type: :numeric, default: Inputs::Corpus::DEFAULT_MAX_CASES,
73
99
  desc: "max input cases per method"
100
+ method_option :method, type: :string, banner: "NAME",
101
+ desc: "the method to pin, when discovery would guess or refuse"
74
102
  def plan(target)
75
103
  guarded do
76
- file, method = Analyzer::TargetParser.split_target(target)
104
+ file, method = resolve_target(target)
77
105
  target_profile = Analyzer::TargetParser.parse(file, method)
78
106
 
79
107
  print_profile(target_profile)
@@ -116,6 +144,8 @@ module Pinspec
116
144
  desc: "probe boots; 2 is the default because one process shares warm caches"
117
145
  method_option :"compare-sql", type: :boolean, default: false,
118
146
  desc: "include SQL fingerprints in the stability decision"
147
+ method_option :method, type: :string, banner: "NAME",
148
+ desc: "the method to pin, when discovery would guess or refuse"
119
149
  method_option :"app-env", type: :string, repeatable: true, banner: "KEY=VALUE",
120
150
  desc: "environment for the app's own runtime (when it is not this shell's Ruby)"
121
151
  method_option :sample, type: :boolean, default: false,
@@ -125,7 +155,7 @@ module Pinspec
125
155
  def capture(*args)
126
156
  guarded do
127
157
  target = target_from(args)
128
- file, method = Analyzer::TargetParser.split_target(target)
158
+ file, method = resolve_target(target)
129
159
 
130
160
  result = Runner::Capture.new(
131
161
  app_root: options[:app],
@@ -153,18 +183,19 @@ module Pinspec
153
183
  method_option :"skip-verify", type: :boolean, default: false
154
184
  method_option :force, type: :boolean, default: false,
155
185
  desc: "overwrite a pin file that has been hand-edited"
156
- method_option :snapshot, type: :string, default: "inline", enum: %w[inline insta approvals],
157
- desc: "snapshot backend"
186
+ method_option :method, type: :string, banner: "NAME",
187
+ desc: "the method to pin, when discovery would guess or refuse"
158
188
  method_option :"app-env", type: :string, repeatable: true, banner: "KEY=VALUE",
159
189
  desc: "environment for the app's own runtime (when it is not this shell's Ruby)"
160
190
  method_option :sample, type: :boolean, default: false,
161
191
  desc: "read real rows through a generated read-only script in the app"
162
192
  method_option :"no-redact", type: :boolean, default: false,
163
193
  desc: "do NOT rewrite personal data in sampled rows (they land in a committed file)"
194
+ method_option :snapshot, type: :string, hide: true
164
195
  def pin(*args)
165
196
  guarded do
166
197
  target = target_from(args)
167
- refuse_unbuilt_backend!
198
+ warn_about_retired_flags!
168
199
  warn_about_redaction!
169
200
 
170
201
  return pin_directory(target) if File.directory?(target)
@@ -178,7 +209,12 @@ module Pinspec
178
209
  files = Batch.targets_in(path)
179
210
  raise TargetNotFound, "no .rb files under #{path}" if files.empty?
180
211
 
181
- puts "pinning #{files.size} file(s) under #{path}"
212
+ # Counted once over the directory: the method name this application uses for
213
+ # its entry points. chatwoot says `perform`, mastodon says `call`.
214
+ @convention = Analyzer::Discovery.convention_for(files)
215
+
216
+ puts "pinning #{files.size} file(s) under #{path}" \
217
+ "#{@convention ? " (this app's convention: ##{@convention})" : ''}"
182
218
  puts
183
219
 
184
220
  report = Batch.new(files) { |file| pin_one(file, quiet: true) }.run
@@ -190,13 +226,13 @@ module Pinspec
190
226
  end
191
227
 
192
228
  def pin_one(target, quiet: false)
193
- file, method = Analyzer::TargetParser.split_target(target)
229
+ file, method = resolve_target(target)
194
230
 
195
231
  capture = Runner::Capture.new(
196
232
  app_root: options[:app], target: file, method: method,
197
233
  max_cases: setting('cases', Inputs::Corpus::DEFAULT_MAX_CASES),
198
234
  boots: setting('boots', 2), sandbox_env: app_env,
199
- sample: setting('sample', false), redact: !options[:"no-redact"]
235
+ sample: setting('sample', false), redact: !options[:"no-redact"] && setting("redact", true)
200
236
  ).run
201
237
 
202
238
  print_capture(capture) unless quiet
@@ -255,12 +291,14 @@ module Pinspec
255
291
  method_option :cases, type: :numeric, default: Inputs::Corpus::DEFAULT_MAX_CASES
256
292
  method_option :"test-command", type: :string,
257
293
  desc: "run the app's suite in its own runtime (for apps on Ruby < 3.4)"
294
+ method_option :method, type: :string, banner: "NAME",
295
+ desc: "the method to pin, when discovery would guess or refuse"
258
296
  method_option :"app-env", type: :string, repeatable: true, banner: "KEY=VALUE",
259
297
  desc: "environment for the app's own runtime"
260
298
  def validate(*args)
261
299
  guarded do
262
300
  target = target_from(args)
263
- file, method = Analyzer::TargetParser.split_target(target)
301
+ file, method = resolve_target(target)
264
302
 
265
303
  capture = Runner::Capture.new(
266
304
  app_root: options[:app], target: file, method: method,
@@ -322,9 +360,7 @@ module Pinspec
322
360
  @legacy_env = pairs
323
361
 
324
362
  if targets.empty?
325
- raise TargetNotFound,
326
- "no target given. Pass a file, a FILE#METHOD, or a directory: " \
327
- "pinspec pin app/services/invoice_calculator.rb"
363
+ raise TargetNotFound, no_target_message
328
364
  end
329
365
 
330
366
  if targets.size > 1
@@ -342,6 +378,90 @@ module Pinspec
342
378
  "Prefer `--app-env A=1 --app-env B=2`, or record it once with `pinspec init`."
343
379
  end
344
380
 
381
+ # `pinspec pin app/services/foo.rb` used to assume #call. Across five public Rails
382
+ # codebases only 14% of service files had a resolvable one - chatwoot's are named
383
+ # `perform` - so the method is discovered instead, using the convention the
384
+ # application itself follows.
385
+ def resolve_target(target)
386
+ file, method = Analyzer::TargetParser.split_target(target)
387
+
388
+ unless File.file?(file) || File.directory?(file)
389
+ raise TargetNotFound,
390
+ "no file at #{file}. Pass a path to a Ruby file, a FILE#METHOD, or a " \
391
+ "directory - paths are relative to where you are, not to --app."
392
+ end
393
+
394
+ return [file, method] if method
395
+ return [file, options[:method]] if options[:method]
396
+
397
+ choice = Analyzer::Discovery.new(file).choose(convention: @convention)
398
+
399
+ if choice.ambiguous?
400
+ raise AmbiguousTarget, ambiguous_message(file, choice)
401
+ end
402
+
403
+ @chosen = choice
404
+ [file, existing_pin_method(file, choice) || choice.descriptor]
405
+ end
406
+
407
+ # Discovery follows the application's convention, which can differ from what an
408
+ # older pinspec chose - it always assumed #call. Re-pinning a class under a new
409
+ # method would leave the previous pin sitting in the suite, unmaintained and still
410
+ # running. So an existing pin for this class decides.
411
+ def existing_pin_method(file, choice)
412
+ dir = File.join(options[:app], Emit::SpecWriter::SPEC_DIR)
413
+ return nil unless File.directory?(dir)
414
+
415
+ profile = Analyzer::TargetParser.parse(file, choice.descriptor)
416
+ stem = Emit::SpecWriter.class_stem(profile.class_name)
417
+
418
+ # A pin's filename cannot carry `?` or `!`, so compare on the same stripped
419
+ # form the filename uses - otherwise a pinned `#valid?` reads back as `valid`,
420
+ # which is not a method the class has.
421
+ stripped = ->(name) { name.to_s.gsub(/[^a-z0-9_]/i, "") }
422
+ pinned = Dir.glob(File.join(dir, "#{stem}_*_spec.rb"))
423
+ .map { |path| File.basename(path).delete_prefix("#{stem}_").delete_suffix("_spec.rb") }
424
+ .reject { |name| name == stripped.call(choice.method_name) }
425
+
426
+ return nil unless pinned.size == 1
427
+
428
+ # Resolve the stem back to the method the class really defines, so `?` and `!`
429
+ # survive the round trip.
430
+ surface = Analyzer::Discovery.new(file).surface
431
+ real = (surface[:instance] + surface[:singleton]).find { |name| stripped.call(name) == pinned.first }
432
+ return nil if real.nil?
433
+
434
+ warn "pinspec: keeping ##{real}, which this class is already pinned on. " \
435
+ "Discovery would have chosen ##{choice.method_name}; pass " \
436
+ "#{File.basename(file)}##{choice.method_name} to switch."
437
+ real
438
+ rescue StandardError
439
+ nil
440
+ end
441
+
442
+ # --skip-verify means nothing was verified, so the summary must not say it was.
443
+ def no_target_message
444
+ return "no spec file given: pinspec verify spec/models/order_spec.rb" if current_command_chain.first == :verify
445
+
446
+ "no target given. Pass a file, a FILE#METHOD, or a directory: " \
447
+ "pinspec pin app/services/invoice_calculator.rb"
448
+ end
449
+
450
+ def verified_word
451
+ options[:"skip-verify"] ? "not verified" : "verified"
452
+ end
453
+
454
+ def ambiguous_message(file, choice)
455
+ if choice.reason == :no_public_methods
456
+ "#{File.basename(file)} defines no public method to pin. Name one explicitly " \
457
+ "if it is private on purpose: #{File.basename(file)}#the_method"
458
+ else
459
+ "#{File.basename(file)} defines several public methods and none is a " \
460
+ "conventional entry point (#{choice.candidates.join(', ')}). Name the one " \
461
+ "you mean: #{File.basename(file)}##{choice.candidates.first}"
462
+ end
463
+ end
464
+
345
465
  def config
346
466
  @config ||= Config.load(options[:app] || ".")
347
467
  end
@@ -362,14 +482,14 @@ module Pinspec
362
482
  config.value(key, options[key.to_sym], default)
363
483
  end
364
484
 
365
- def refuse_unbuilt_backend!
366
- backend = options[:snapshot]
367
- return if backend.nil? || backend == "inline"
485
+ # `--snapshot` selected a backend whose only implementation refused two of its
486
+ # three values. It is accepted and ignored so that upgrading does not break a
487
+ # script that passed it.
488
+ def warn_about_retired_flags!
489
+ return if options[:snapshot].nil?
368
490
 
369
- raise VerifyFailed,
370
- "the #{backend} snapshot backend is not built yet; only `inline` is. " \
371
- "Inline snapshots keep the pinned value in the spec file, where a reviewer " \
372
- "can read it - which is why it is the default. Re-run without --snapshot."
491
+ warn "pinspec: --snapshot was removed and is ignored. A pin has always been " \
492
+ "inline literals, which is what that flag selected by default."
373
493
  end
374
494
 
375
495
  def warn_about_redaction!
@@ -405,7 +525,7 @@ module Pinspec
405
525
  return if graph.skipped_statements.empty?
406
526
 
407
527
  puts
408
- puts " hazards (relevance is decided once a plan exists, in M-05):"
528
+ puts " hazards - statements pinspec could not read; relevant only if a plan needs these tables:"
409
529
  graph.skipped_statements.each { |statement| puts " #{statement}" }
410
530
  end
411
531
 
@@ -736,7 +856,8 @@ module Pinspec
736
856
  name = File.basename(outcome.file).ljust(width)
737
857
 
738
858
  puts case outcome.status
739
- when :pinned then format(" pinned %s %d case(s), verified", name, outcome.pinned)
859
+ when :pinned then format(" pinned %s %-10s %d case(s), #{verified_word}",
860
+ name, "##{outcome.target.to_s[/#(.+)\z/, 1]}", outcome.pinned)
740
861
  when :refused then format(" skipped %s %s", name, outcome.detail)
741
862
  else format(" FAILED %s %s", name, outcome.detail)
742
863
  end
@@ -6,7 +6,11 @@ module Pinspec
6
6
  class Config
7
7
  FILENAME = ".pinspec.yml"
8
8
 
9
- KEYS = %w[cases boots sample redact compare-sql verify-level snapshot test-command env].freeze
9
+ KEYS = %w[cases boots sample redact compare-sql verify-level test-command env].freeze
10
+
11
+ # Keys that used to be valid. A config written for an older pinspec is ignored
12
+ # with a note rather than failing the run - an upgrade should not stop a build.
13
+ RETIRED_KEYS = %w[snapshot].freeze
10
14
 
11
15
  EMPTY = { "env" => {} }.freeze
12
16
 
@@ -17,7 +21,14 @@ module Pinspec
17
21
  parsed = YAML.safe_load(Analyzer::Source.read(path), permitted_classes: [], aliases: false) || {}
18
22
  raise ConfigInvalid, "#{path} must contain a mapping, got #{parsed.class}" unless parsed.is_a?(Hash)
19
23
 
20
- unknown = parsed.keys.map(&:to_s) - KEYS
24
+ retired = parsed.keys.map(&:to_s) & RETIRED_KEYS
25
+ unless retired.empty?
26
+ warn "pinspec: #{path} sets #{retired.map(&:inspect).join(', ')}, which " \
27
+ "#{retired.size == 1 ? 'was' : 'were'} removed. Ignoring #{retired.size == 1 ? 'it' : 'them'}; " \
28
+ "delete the line to silence this."
29
+ end
30
+
31
+ unknown = parsed.keys.map(&:to_s) - KEYS - RETIRED_KEYS
21
32
  unless unknown.empty?
22
33
  raise ConfigInvalid,
23
34
  "#{path} has unknown #{unknown.size == 1 ? 'key' : 'keys'} " \