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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +206 -0
- data/README.md +80 -18
- data/lib/pinspec/analyzer/discovery.rb +162 -0
- data/lib/pinspec/analyzer/factory_registry.rb +13 -4
- data/lib/pinspec/analyzer/target_parser.rb +272 -34
- data/lib/pinspec/batch.rb +80 -0
- data/lib/pinspec/cli.rb +347 -59
- data/lib/pinspec/config.rb +92 -0
- data/lib/pinspec/emit/spec_writer.rb +26 -22
- data/lib/pinspec/errors.rb +2 -2
- data/lib/pinspec/inputs/boundary.rb +2 -3
- data/lib/pinspec/inputs/sample_runner.rb +19 -6
- 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/runtime.rb +131 -0
- data/lib/pinspec/runner/sandbox.rb +45 -27
- data/lib/pinspec/setup/context_builder.rb +10 -7
- data/lib/pinspec/types.rb +2 -55
- data/lib/pinspec/verify/verifier.rb +40 -5
- data/lib/pinspec/version.rb +1 -1
- data/templates/factory_build.rb +10 -11
- metadata +5 -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,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
|
-
|
|
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
|
|
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
|
-
|
|
89
|
+
[text, nil]
|
|
71
90
|
end
|
|
72
91
|
end
|
|
73
92
|
|
|
74
|
-
|
|
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
|
-
|
|
412
|
-
|
|
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
|
-
|
|
423
|
-
|
|
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
|
-
|
|
426
|
-
|
|
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
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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
|
|
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
|