mock-suey 0.0.1 → 0.1.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.
@@ -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