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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +128 -0
- data/README.md +24 -3
- data/lib/pinspec/analyzer/discovery.rb +162 -0
- data/lib/pinspec/analyzer/factory_registry.rb +13 -4
- data/lib/pinspec/analyzer/target_parser.rb +266 -33
- data/lib/pinspec/cli.rb +143 -22
- data/lib/pinspec/config.rb +13 -2
- data/lib/pinspec/emit/spec_writer.rb +26 -22
- data/lib/pinspec/errors.rb +0 -4
- data/lib/pinspec/inputs/boundary.rb +2 -3
- data/lib/pinspec/inputs/sample_runner.rb +16 -3
- data/lib/pinspec/inputs/sampler.rb +0 -32
- data/lib/pinspec/report/summary.rb +30 -0
- data/lib/pinspec/runner/capture.rb +3 -1
- data/lib/pinspec/runner/probe_generator.rb +4 -1
- data/lib/pinspec/runner/sandbox.rb +19 -9
- data/lib/pinspec/setup/context_builder.rb +10 -7
- data/lib/pinspec/types.rb +2 -55
- data/lib/pinspec/verify/verifier.rb +37 -2
- data/lib/pinspec/version.rb +1 -1
- data/templates/factory_build.rb +10 -11
- metadata +2 -2
- data/lib/pinspec/emit/namer.rb +0 -103
|
@@ -19,13 +19,26 @@ module Pinspec
|
|
|
19
19
|
|
|
20
20
|
VISIBILITIES = %i[public private protected].freeze
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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,
|
|
89
|
+
[text, nil]
|
|
76
90
|
end
|
|
77
91
|
end
|
|
78
92
|
|
|
79
|
-
|
|
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
|
-
|
|
417
|
-
|
|
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
|
-
|
|
428
|
-
|
|
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
|
-
|
|
431
|
-
|
|
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
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
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
|
|
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 =
|
|
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 =
|
|
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 :
|
|
157
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
|
-
|
|
370
|
-
|
|
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
|
|
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),
|
|
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
|
data/lib/pinspec/config.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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'} " \
|