mock-suey 0.0.1 → 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MockSuey
4
+ class Tracer
5
+ class PrependCollector
6
+ attr_reader :tracer
7
+
8
+ def initialize(tracer)
9
+ @tracer = tracer
10
+ end
11
+
12
+ def module_for(klass, methods)
13
+ tracer = self.tracer
14
+
15
+ Module.new do
16
+ define_method(:__mock_suey_tracer__) { tracer }
17
+
18
+ methods.each do |mid|
19
+ module_eval <<~RUBY, __FILE__, __LINE__ + 1
20
+ def #{mid}(*args, **kwargs, &block)
21
+ super.tap do |return_value|
22
+ arguments = args
23
+ arguments << kwargs unless kwargs.empty?
24
+
25
+ __mock_suey_tracer__ << MethodCall.new(
26
+ receiver_class: self.class,
27
+ method_name: __method__,
28
+ arguments: arguments,
29
+ has_kwargs: !kwargs.empty?,
30
+ return_value: return_value,
31
+ metadata: {
32
+ location: method(__method__).super_method.source_location.first
33
+ }
34
+ )
35
+ end
36
+ end
37
+ RUBY
38
+ end
39
+ end
40
+ end
41
+
42
+ def setup(targets)
43
+ targets.each do |klass, methods|
44
+ mod = module_for(klass, methods)
45
+ klass.prepend(mod)
46
+ end
47
+ end
48
+
49
+ def stop
50
+ end
51
+ end
52
+
53
+ class TracePointCollector
54
+ attr_reader :tracer
55
+
56
+ def initialize(tracer)
57
+ @tracer = tracer
58
+ end
59
+
60
+ def setup(targets)
61
+ tracer = self.tracer
62
+ calls_stack = []
63
+
64
+ @tp = TracePoint.trace(:call, :return) do |tp|
65
+ methods = targets[tp.defined_class]
66
+ next unless methods
67
+ next unless methods.include?(tp.method_id)
68
+
69
+ receiver_class, method_name = tp.defined_class, tp.method_id
70
+
71
+ if tp.event == :call
72
+ method = tp.self.method(method_name)
73
+ arguments = []
74
+ kwargs = {}
75
+
76
+ method.parameters.each do |(type, name)|
77
+ next if name == :** || name == :* || name == :&
78
+
79
+ val = tp.binding.local_variable_get(name)
80
+
81
+ case type
82
+ when :req, :opt
83
+ arguments << val
84
+ when :keyreq, :key
85
+ kwargs[name] = val
86
+ when :rest
87
+ arguments.concat(val)
88
+ when :keyrest
89
+ kwargs.merge!(val)
90
+ end
91
+ end
92
+
93
+ arguments << kwargs unless kwargs.empty?
94
+
95
+ call_obj = MethodCall.new(
96
+ receiver_class: receiver_class,
97
+ method_name: method_name,
98
+ arguments: arguments,
99
+ has_kwargs: !kwargs.empty?,
100
+ metadata: {
101
+ location: method.source_location.first
102
+ }
103
+ )
104
+ tracer << call_obj
105
+ calls_stack << call_obj
106
+ elsif tp.event == :return
107
+ call_obj = calls_stack.pop
108
+ call_obj.return_value = tp.return_value
109
+ end
110
+ end
111
+ end
112
+
113
+ def stop
114
+ tp.disable
115
+ end
116
+
117
+ private
118
+
119
+ attr_reader :tp
120
+ end
121
+
122
+ attr_reader :store, :targets, :collector
123
+
124
+ def initialize(via: :prepend)
125
+ @store = []
126
+ @targets = Hash.new { |h, k| h[k] = [] }
127
+ @collector =
128
+ if via == :prepend
129
+ PrependCollector.new(self)
130
+ elsif via == :trace_point
131
+ TracePointCollector.new(self)
132
+ else
133
+ raise ArgumentError, "Unknown tracing method: #{via}"
134
+ end
135
+ end
136
+
137
+ def collect(klass, methods)
138
+ targets[klass].concat(methods)
139
+ targets[klass].uniq!
140
+ end
141
+
142
+ def start!
143
+ collector.setup(targets)
144
+ end
145
+
146
+ def stop
147
+ collector.stop
148
+ total = store.size
149
+ filter_calls!
150
+ MockSuey.logger.debug "Collected #{store.size} real calls (#{total - store.size} were filtered)"
151
+ store
152
+ end
153
+
154
+ def <<(call_obj)
155
+ store << call_obj
156
+ end
157
+
158
+ private
159
+
160
+ def filter_calls!
161
+ store.reject! { mocked?(_1) }
162
+ end
163
+
164
+ def mocked?(call_obj)
165
+ location = call_obj.metadata[:location]
166
+
167
+ location.match?(%r{/lib/rspec/mocks/}) ||
168
+ call_obj.return_value.is_a?(::RSpec::Mocks::Double) ||
169
+ call_obj.arguments.any? { _1.is_a?(::RSpec::Mocks::Double) } ||
170
+ call_obj.kwargs.values.any? { _1.is_a?(::RSpec::Mocks::Double) }
171
+ end
172
+ end
173
+ end
@@ -0,0 +1,251 @@
1
+ # frozen_string_literal: true
2
+
3
+ gem "rbs", "~> 2.0"
4
+ require "rbs"
5
+ require "rbs/test"
6
+
7
+ require "set"
8
+ require "pathname"
9
+
10
+ require "mock_suey/ext/instance_class"
11
+
12
+ module MockSuey
13
+ module TypeChecks
14
+ using Ext::InstanceClass
15
+
16
+ class Ruby
17
+ class SignatureGenerator
18
+ attr_reader :klass, :method_calls, :constants, :singleton
19
+ alias_method :singleton?, :singleton
20
+
21
+ def initialize(klass, method_calls)
22
+ @klass = klass
23
+ @singleton = klass.singleton_class?
24
+ @method_calls = method_calls
25
+ @constants = Set.new
26
+ end
27
+
28
+ def to_rbs
29
+ [
30
+ header,
31
+ *method_calls.map { |name, calls| method_sig(name, calls) },
32
+ footer
33
+ ].join("\n")
34
+ end
35
+
36
+ private
37
+
38
+ def header
39
+ nesting_parts = klass.instance_class_name.split("::")
40
+
41
+ base = Kernel
42
+ nesting = 0
43
+
44
+ lines = []
45
+
46
+ nesting_parts.map do |const|
47
+ base = base.const_get(const)
48
+ lines << "#{" " * nesting}#{base.is_a?(Class) ? "class" : "module"} #{const}"
49
+ nesting += 1
50
+ end
51
+
52
+ @nesting = nesting_parts.size
53
+
54
+ lines.join("\n")
55
+ end
56
+
57
+ def footer
58
+ @nesting.times.map do |n|
59
+ "#{" " * (@nesting - n - 1)}end"
60
+ end.join("\n")
61
+ end
62
+
63
+ def method_sig(name, calls)
64
+ "#{" " * @nesting}def #{singleton? ? "self." : ""}#{name}: (#{[args_sig(calls.map(&:pos_args)), kwargs_sig(calls.map(&:kwargs))].compact.join(", ")}) -> (#{return_sig(name, calls.map(&:return_value))})"
65
+ end
66
+
67
+ def args_sig(args)
68
+ return if args.all?(&:empty?)
69
+
70
+ args.transpose.map do |arg_values|
71
+ arg_values.map(&:class).uniq.map do
72
+ constants << _1
73
+ "::#{_1.name}"
74
+ end
75
+ end.join(", ")
76
+ end
77
+
78
+ def kwargs_sig(kwargs)
79
+ return if kwargs.all?(&:empty?)
80
+
81
+ key_values = kwargs.each_with_object(Hash.new { |h, k| h[k] = [] }) { |pairs, acc|
82
+ pairs.each { acc[_1] << _2 }
83
+ }
84
+
85
+ key_values.map do |key, values|
86
+ values_sig = values.map(&:class).uniq.map do
87
+ constants << _1
88
+ "::#{_1.name}"
89
+ end.join(" | ")
90
+
91
+ "?#{key}: (#{values_sig})"
92
+ end.join(", ")
93
+ end
94
+
95
+ def return_sig(name, values)
96
+ # Special case
97
+ return "self" if name == :initialize
98
+
99
+ values.map(&:class).uniq.map do
100
+ constants << _1
101
+ "::#{_1.name}"
102
+ end.join(" | ")
103
+ end
104
+ end
105
+
106
+ def initialize(load_dirs: [])
107
+ @load_dirs = Array(load_dirs)
108
+ end
109
+
110
+ def typecheck!(call_obj, raise_on_missing: false)
111
+ method_name = call_obj.method_name
112
+
113
+ method_call = RBS::Test::ArgumentsReturn.return(
114
+ arguments: call_obj.arguments,
115
+ value: call_obj.return_value
116
+ )
117
+
118
+ call_trace = RBS::Test::CallTrace.new(
119
+ method_name: method_name,
120
+ method_call: method_call,
121
+ # TODO: blocks support
122
+ block_calls: [],
123
+ block_given: false
124
+ )
125
+
126
+ method_type = type_for(call_obj.receiver_class, method_name)
127
+
128
+ unless method_type
129
+ raise MissingSignature, "No signature found for #{call_obj.method_desc}" if raise_on_missing
130
+ return
131
+ end
132
+
133
+ self_class = call_obj.receiver_class
134
+ instance_class = call_obj.receiver_class
135
+ class_class = call_obj.receiver_class.singleton_class? ? call_obj.receiver_class : call_obj.receiver_class.singleton_class
136
+
137
+ typecheck = RBS::Test::TypeCheck.new(
138
+ self_class: self_class,
139
+ builder: builder,
140
+ sample_size: 100, # What should be the value here?
141
+ unchecked_classes: [],
142
+ instance_class: instance_class,
143
+ class_class: class_class
144
+ )
145
+
146
+ errors = []
147
+ typecheck.overloaded_call(
148
+ method_type,
149
+ "#{self_class.singleton_class? ? "." : "#"}#{method_name}",
150
+ call_trace,
151
+ errors: errors
152
+ )
153
+
154
+ reject_returned_doubles!(errors)
155
+
156
+ # TODO: Use custom error class
157
+ raise RBS::Test::Tester::TypeError.new(errors) unless errors.empty?
158
+ end
159
+
160
+ def load_signatures_from_calls(calls)
161
+ constants = Set.new
162
+
163
+ calls.group_by(&:receiver_class).each do |klass, klass_calls|
164
+ calls_per_method = klass_calls.group_by(&:method_name)
165
+ generator = SignatureGenerator.new(klass, calls_per_method)
166
+
167
+ generator.to_rbs.then do |rbs|
168
+ MockSuey.logger.debug "Generated RBS for #{klass.instance_class_name}:\n#{rbs}\n"
169
+ load_rbs(rbs)
170
+ end
171
+
172
+ constants |= generator.constants
173
+ end
174
+
175
+ constants.each do |const|
176
+ next if type_defined?(const)
177
+
178
+ SignatureGenerator.new(const, {}).to_rbs.then do |rbs|
179
+ MockSuey.logger.debug "Generated RBS for constant #{const.instance_class_name}:\n#{rbs}\n"
180
+ load_rbs(rbs)
181
+ end
182
+ end
183
+ end
184
+
185
+ private
186
+
187
+ def load_rbs(rbs)
188
+ ::RBS::Parser.parse_signature(rbs).then do |declarations|
189
+ declarations.each do |decl|
190
+ env << decl
191
+ end
192
+ end
193
+ end
194
+
195
+ def env
196
+ return @env if instance_variable_defined?(:@env)
197
+
198
+ loader = RBS::EnvironmentLoader.new
199
+ @load_dirs&.each { loader.add(path: Pathname(_1)) }
200
+ @env = RBS::Environment.from_loader(loader).resolve_type_names
201
+ end
202
+
203
+ def builder ; @builder ||= RBS::DefinitionBuilder.new(env: env); end
204
+
205
+ def type_for(klass, method_name)
206
+ type = type_for_class(klass.instance_class)
207
+ return unless env.class_decls[type]
208
+
209
+ decl = klass.singleton_class? ? builder.build_singleton(type) : builder.build_instance(type)
210
+
211
+ decl.methods[method_name]
212
+ end
213
+
214
+ def type_for_class(klass)
215
+ *path, name = *klass.instance_class_name.split("::").map(&:to_sym)
216
+
217
+ namespace = path.empty? ? RBS::Namespace.root : RBS::Namespace.new(absolute: true, path: path)
218
+
219
+ RBS::TypeName.new(name: name, namespace: namespace)
220
+ end
221
+
222
+ def type_defined?(klass)
223
+ !env.class_decls[type_for_class(klass.instance_class)].nil?
224
+ end
225
+
226
+ def reject_returned_doubles!(errors)
227
+ return unless defined?(::RSpec::Core)
228
+
229
+ errors.reject! do |error|
230
+ case error
231
+ in RBS::Test::Errors::ReturnTypeError[
232
+ type:,
233
+ value: ::RSpec::Mocks::InstanceVerifyingDouble => double
234
+ ]
235
+ return_class = type.instance_of?(RBS::Types::Bases::Self) ? error.klass : type.name
236
+ return_type = return_class.to_s.gsub(/^::/, "")
237
+ double_type = double.instance_variable_get(:@doubled_module).target.to_s
238
+
239
+ double_type == return_type
240
+ in RBS::Test::Errors::ReturnTypeError[
241
+ value: ::RSpec::Mocks::Double
242
+ ]
243
+ true
244
+ else
245
+ false
246
+ end
247
+ end
248
+ end
249
+ end
250
+ end
251
+ end
@@ -0,0 +1,246 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MockSuey
4
+ class Configuration
5
+ # No freezing this const to allow third-party libraries
6
+ # to integrate with mock_suey
7
+ TYPE_CHECKERS = %w[ruby]
8
+
9
+ attr_accessor :debug,
10
+ :logger,
11
+ :log_level,
12
+ :color,
13
+ :store_mocked_calls,
14
+ :signature_load_dirs,
15
+ :raise_on_missing_types,
16
+ :raise_on_missing_auto_types,
17
+ :trace_real_calls,
18
+ :trace_real_calls_via
19
+
20
+ attr_reader :type_check, :auto_type_check, :verify_mock_contracts
21
+
22
+ def initialize
23
+ @debug = %w[1 y yes true t].include?(ENV["MOCK_SUEY_DEBUG"])
24
+ @log_level = debug ? :debug : :info
25
+ @color = nil
26
+ @store_mocked_calls = false
27
+ @type_check = nil
28
+ @signature_load_dirs = ["sig"]
29
+ @raise_on_missing_types = false
30
+ @raise_on_missing_auto_types = true
31
+ @trace_real_calls = false
32
+ @auto_type_check = false
33
+ @trace_real_calls_via = :prepend
34
+ end
35
+
36
+ def color?
37
+ return color unless color.nil?
38
+
39
+ logdev = logger.instance_variable_get(:@logdev)
40
+ return self.color = false unless logdev
41
+
42
+ output = logdev.instance_variable_get(:@dev)
43
+ return self.color = false unless output
44
+
45
+ self.color = output.is_a?(IO) && output.tty?
46
+ end
47
+
48
+ def type_check=(val)
49
+ if val.nil?
50
+ @type_check = nil
51
+ return
52
+ end
53
+
54
+ val = val.to_s
55
+ raise ArgumentError, "Unsupported type checker: #{val}. Supported: #{TYPE_CHECKERS.join(",")}" unless TYPE_CHECKERS.include?(val)
56
+
57
+ @type_check = val
58
+ end
59
+
60
+ def auto_type_check=(val)
61
+ if val
62
+ @trace_real_calls = true
63
+ @store_mocked_calls = true
64
+ @auto_type_check = true
65
+ else
66
+ @auto_type_check = val
67
+ end
68
+ end
69
+
70
+ def verify_mock_contracts=(val)
71
+ if val
72
+ @trace_real_calls = true
73
+ @verify_mock_contracts = true
74
+ else
75
+ @verify_mock_contracts = val
76
+ end
77
+ end
78
+ end
79
+
80
+ class << self
81
+ attr_reader :stored_mocked_calls, :tracer, :stored_real_calls
82
+ attr_accessor :type_checker
83
+
84
+ def config = @config ||= Configuration.new
85
+
86
+ def configure = yield config
87
+
88
+ def logger = config.logger
89
+
90
+ def on_mocked_call(&block)
91
+ on_mocked_callbacks << block
92
+ end
93
+
94
+ def handle_mocked_call(call_obj)
95
+ on_mocked_callbacks.each { _1.call(call_obj) }
96
+ end
97
+
98
+ def on_mocked_callbacks
99
+ @on_mocked_callbacks ||= []
100
+ end
101
+
102
+ # Load extensions and start tracing if required
103
+ def cook
104
+ setup_logger
105
+ setup_type_checker
106
+ setup_mocked_calls_collection if config.store_mocked_calls
107
+ setup_real_calls_collection if config.trace_real_calls
108
+ end
109
+
110
+ # Run post-suite checks
111
+ def eat
112
+ @stored_real_calls = tracer.stop if config.trace_real_calls
113
+
114
+ offenses = []
115
+
116
+ if config.store_mocked_calls
117
+ logger.debug { "Stored mocked calls:\n#{stored_mocked_calls.map { " #{_1.inspect}" }.join("\n")}" }
118
+ end
119
+
120
+ if config.trace_real_calls
121
+ logger.debug { "Traced real calls:\n#{stored_real_calls.map { " #{_1.inspect}" }.join("\n")}" }
122
+ end
123
+
124
+ if config.auto_type_check
125
+ perform_auto_type_check(offenses)
126
+ end
127
+
128
+ if config.verify_mock_contracts
129
+ perform_contracts_verification(offenses)
130
+ end
131
+
132
+ offenses
133
+ end
134
+
135
+ private
136
+
137
+ def setup_logger
138
+ if !config.logger || config.debug
139
+ config.logger = Logger.new($stdout)
140
+ config.logger.formatter = Logging::Formatter.new
141
+ end
142
+ config.logger.level = config.log_level
143
+ end
144
+
145
+ def setup_type_checker
146
+ return unless config.type_check
147
+
148
+ # Allow configuring type checher manually
149
+ unless type_checker
150
+ require "mock_suey/type_checks/#{config.type_check}"
151
+ const_name = config.type_check.split("_").map(&:capitalize).join
152
+
153
+ self.type_checker = MockSuey::TypeChecks.const_get(const_name)
154
+ .new(load_dirs: config.signature_load_dirs)
155
+
156
+ logger.info "Set up type checker: #{type_checker.class.name} (load_dirs: #{config.signature_load_dirs})"
157
+ end
158
+
159
+ raise_on_missing = config.raise_on_missing_types
160
+
161
+ on_mocked_call do |call_obj|
162
+ type_checker.typecheck!(call_obj, raise_on_missing:)
163
+ end
164
+ end
165
+
166
+ def setup_mocked_calls_collection
167
+ logger.info "Collect mocked calls (MockSuey.stored_mocked_calls)"
168
+
169
+ @stored_mocked_calls = []
170
+
171
+ on_mocked_call { @stored_mocked_calls << _1 }
172
+ end
173
+
174
+ def setup_real_calls_collection
175
+ logger.info "Collect real calls via #{config.trace_real_calls_via} (MockSuey.stored_real_calls)"
176
+
177
+ @tracer = Tracer.new(via: config.trace_real_calls_via)
178
+
179
+ MockSuey::RSpec::MockContext.registry.each do |klass, methods|
180
+ logger.debug { "Trace #{klass} methods: #{methods.keys.join(", ")}" }
181
+ tracer.collect(klass, methods.keys)
182
+ end
183
+
184
+ tracer.start!
185
+ end
186
+
187
+ def perform_auto_type_check(offenses)
188
+ raise "No type checker configured" unless type_checker
189
+
190
+ # Generate signatures
191
+ type_checker.load_signatures_from_calls(stored_real_calls)
192
+
193
+ logger.info "Type-checking mocked calls against auto-generated signatures..."
194
+
195
+ was_offenses = offenses.size
196
+
197
+ # Verify stored mocked calls
198
+ raise_on_missing = config.raise_on_missing_auto_types
199
+
200
+ stored_mocked_calls.each do |call_obj|
201
+ type_checker.typecheck!(call_obj, raise_on_missing:)
202
+ rescue RBS::Test::Tester::TypeError, TypeChecks::MissingSignature => err
203
+ call_obj.metadata[:error] = err
204
+ offenses << call_obj
205
+ end
206
+
207
+ failed_count = offenses.size - was_offenses
208
+ failed = failed_count > 0
209
+
210
+ if failed
211
+ logger.error "❌ Type-checking completed. Failed examples: #{failed_count}"
212
+ else
213
+ logger.info "✅ Type-checking completed. All good"
214
+ end
215
+ end
216
+
217
+ def perform_contracts_verification(offenses)
218
+ logger.info "Verifying mock contracts..."
219
+ real_calls_per_class_method = stored_real_calls.group_by(&:receiver_class).tap do |grouped|
220
+ grouped.transform_values! { _1.group_by(&:method_name) }
221
+ end
222
+
223
+ was_offenses = offenses.size
224
+
225
+ MockSuey::RSpec::MockContext.registry.each do |klass, methods|
226
+ methods.values.flatten.each do |stub_call|
227
+ contract = MockContract.from_stub(stub_call)
228
+ logger.debug { "Generated contract:\n #{contract.inspect}\n (from stub: #{stub_call.inspect})" }
229
+ contract.verify!(real_calls_per_class_method.dig(klass, stub_call.method_name))
230
+ rescue MockContract::Error => err
231
+ stub_call.metadata[:error] = err
232
+ offenses << stub_call
233
+ end
234
+ end
235
+
236
+ failed_count = offenses.size - was_offenses
237
+ failed = failed_count > 0
238
+
239
+ if failed
240
+ logger.error "❌ Verifying mock contracts completed. Failed contracts: #{failed_count}"
241
+ else
242
+ logger.info "✅ Verifying mock contracts completed. All good"
243
+ end
244
+ end
245
+ end
246
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MockSuey
4
+ module Ext
5
+ module InstanceClass
6
+ refine Class do
7
+ def instance_class = self
8
+
9
+ def instance_class_name = name
10
+ end
11
+
12
+ refine Class.singleton_class do
13
+ def instance_class
14
+ # TODO: replace with const_get
15
+ eval(instance_class_name) # rubocop:disable Security/Eval
16
+ end
17
+
18
+ def instance_class_name = inspect.sub(%r{^#<Class:}, "").sub(/>$/, "")
19
+ end
20
+ end
21
+ end
22
+ end