type_tracer 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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 5e49de66db3b5881bb0f7b967e38c6b5f629de42
4
+ data.tar.gz: aad4ad204a948e8dc7463a89d9b3325e52b2de73
5
+ SHA512:
6
+ metadata.gz: 818a27edfff25e29d7773ac7784afddd9deda31e48a803ea57d35b670a18cf8a2799466fef4a7187493fa82c353f976ab5248de8a38dc2be75de0ca31ff59f8a
7
+ data.tar.gz: cc2d23289aacd3365347acdd88ac8fe8f856b8f152fa05f076df4374ca06081265aee0b7afcc6c3e278f83751a3a07e5d165f7247b8fe87cb2d1bf6c1d34f769
data/.gitignore ADDED
@@ -0,0 +1,10 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /Gemfile.lock
4
+ /_yardoc/
5
+ /coverage/
6
+ /doc/
7
+ /pkg/
8
+ /spec/reports/
9
+ /tmp/
10
+ Session.vim
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --format documentation
2
+ --color
data/.rubocop.yml ADDED
@@ -0,0 +1,8 @@
1
+ AllCops:
2
+ TargetRubyVersion: 2.3
3
+
4
+ Style/Documentation:
5
+ Enabled: false
6
+
7
+ Metrics/LineLength:
8
+ Max: 99
data/.travis.yml ADDED
@@ -0,0 +1,7 @@
1
+ sudo: false
2
+ cache:
3
+ bundler: true
4
+ language: ruby
5
+ rvm:
6
+ - 2.3.1
7
+ before_install: gem install bundler -v 1.11.2
data/Gemfile ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+ source 'https://rubygems.org'
3
+
4
+ # Specify your gem's dependencies in type_tracer.gemspec
5
+ gemspec
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2016 draffensperger
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,197 @@
1
+ # TypeTracer
2
+
3
+ [![Build Status](https://travis-ci.org/draffensperger/type_tracer.svg?branch=master)](https://travis-ci.org/draffensperger/type_tracer) [![Code Climate](https://codeclimate.com/github/draffensperger/type_tracer.png)](https://codeclimate.com/github/draffensperger/type_tracer)
4
+
5
+ TypeTracer collects a set of experimental approaches to checking Ruby
6
+ "types" (particularly method signatures) while still allowing a decent level of
7
+ metaprogramming and without needing to specify annotations in the production
8
+ code.
9
+
10
+ It includes three proof-of-concept approaches to catch some `NoMethodError`
11
+ cases automatically.
12
+
13
+ See [tt_demos](https://github.com/draffensperger/tt_demos) for an example of using
14
+ approach 1.,
15
+ and [quote-randomizer](https://github.com/draffensperger/quote-randomizer)
16
+ for a simple Rails app that incorporates support for approaches 2. and 3.
17
+
18
+ ### 1. Enhancing RSpec `instance_double` to check stubbed argument values
19
+
20
+ Even if you have 100% unit test coverage, there still can be invalid method
21
+ calls in the "seams" between your classes or groups of classes.
22
+
23
+ RSpec's [Verifying Doubles](https://relishapp.com/rspec/rspec-mocks/docs/verifying-doubles),
24
+ such as `instance_double` will check that stubbed method calls are actually
25
+ defined on a instance of the class you specify and that the method arity (number
26
+ of arguments) matches what you are stubbing.
27
+
28
+ This gem includes a file you can include which monkey-patches `instance_double`
29
+ to parse the abstract syntax tree of the stubbed method to check whether the
30
+ arguments to the stubbed call would result in an `NoMethodError` were they actually to be passed into the stubbed method.
31
+
32
+ To make your `instance_double`'s behave like that, include `type_tracer` in your
33
+ `Gemfile` and add `require 'type_tracer/rspec/Instance_double_arg_checker`.
34
+
35
+ It can only reliably assume that a method will be called on the actual value of
36
+ an argument if that argument variable isn't reassigned and if the method doesn't
37
+ contain any branches (because it could branch on the type of the argument). A
38
+ lot of methods contain branches, so I may try to tighten that requirement (e.g.
39
+ that the expression that it branches on must involve the argument).
40
+
41
+ ### 2. Check instance method calls in app environment (for runtime defined methods)
42
+
43
+ It's very difficult / impossible to simply statically analyze Ruby code for
44
+ undefined methods (though see the
45
+ [ruby-lint](https://github.com/YorickPeterse/ruby-lint) gem for something that
46
+ does a decent job at it).
47
+
48
+ The approach here is instead to combine static and dynamic analysis of a Ruby program.
49
+ That is to run the "undefined method" analysis in the context of a fully loaded
50
+ app environment where many/most of the dynamically defined methods have been
51
+ defined. In a typical Rails app for instance, the `has_many` association methods
52
+ would be defined when a class is loaded.
53
+
54
+ However, the database attribute methods for `ActiveRecord` objects won't be
55
+ defined until a request on that class is made e.g. from a call to `new`. My idea
56
+ would be for `TypeTracer` to provide a config option for a block that could be
57
+ called to induce most of the dynamically defined methods to get defined, e.g. by
58
+ calling `new` on all of the `ActiveRecord::Base` subclasses and perhaps doing
59
+ other application-specific method defining. Of course, not all dynamic methods
60
+ are defined in easy-to-induce ways, but this would still catch a lot of them.
61
+
62
+ To use it, include the `type_tracer` gem in your `Gemfile`, then, assuming
63
+ you're running a Rails app, you can run a new Rake task
64
+ `rake type_tracer:check_method_calls` that will check for undefined top-level
65
+ instance method calls (i.e. calls on `self` in methods).
66
+
67
+ It's possible that you will have methods that are defined not at the time that a
68
+ class is first loaded but later in the app runtime. E.g. ActiveRecord attribute
69
+ methods aren't defined until the first ActiveRecord operation for that model.
70
+
71
+ What `type_tracer` provides is a config option called `attribute_methods_definer`
72
+ that specifies a proc that is called after the app environment is loaded but
73
+ before the analysis starts. Here's an example that will call `new` on all
74
+ classes that inherit from `ActiveRecord::Base` which will have the effect of
75
+ making those classes define their attribute methods.
76
+
77
+ ```
78
+ # config/initializers/type_tracer.rb
79
+ require 'type_tracer'
80
+
81
+ TypeTracer.config do |config|
82
+ config.attribute_methods_definer = proc do
83
+ # initialize all of the active record models so that they will define their
84
+ # attribute methods.
85
+ ActiveRecord::Base.descendants.each(&:new)
86
+ end
87
+ end
88
+ ```
89
+
90
+ This is similar to how you would define attribute methods for a dynamic class if
91
+ you are using RSpec verifying doubles, see their doc for
92
+ [dynamic-classes](https://relishapp.com/rspec/rspec-mocks/docs/verifying-doubles/dynamic-classes)
93
+
94
+ ### 3A. Sampling implied types from production to use in further analysis
95
+
96
+ This is not a direct way to catch bad method calls, but it's an idea to use the
97
+ running production application to gather the real implicit type signature for
98
+ methods and then feed that back into the specs or analysis tools as more
99
+ realistic type information (without needing to annotate production code or be
100
+ super-specific about what the types actually are).
101
+
102
+ It's common, for instance, for a method to take either `nil` or a specific value.
103
+ It's also decently comment for a method to take a "duck type" i.e. it could take
104
+ instances of a range of classes but call a fixed set of methods on all of them.
105
+ Sometimes too a method may have an explicit `is_a?` check and do different
106
+ things for different types (e.g. a method that operates recursively on a nested
107
+ structure that could be either an Array or Hash).
108
+
109
+ To try to represent as many of those cases as possible, the traced types are in
110
+ effect union types of all the different classes that are passed in and what
111
+ methods are called on instances of those different classes.
112
+
113
+ To use type tracing in your Rails app, include the `type_tracer` gem, and then
114
+ add an initializer like this:
115
+
116
+ ```
117
+ # config/initializers/type_tracer.rb
118
+ require 'type_tracer'
119
+
120
+ TypeTracer.config do |config|
121
+ # This configures an Rack middleware for what requests to type sample on
122
+ config.sample_types_for_requests do |_rack_env|
123
+ # Only type sample 1% of requests
124
+ rand() > 0.01
125
+ end
126
+
127
+ # These configure the files to do type sampling on and the remote URL of the
128
+ # sampled types endpoint in a deployed app.
129
+ config.type_check_root_path = Rails.root
130
+ config.type_check_path_regex = %r{\A(app|lib)/}
131
+ config.sampled_types_url = 'https://quote-randomizer.herokuapp.com/sampled_types'
132
+
133
+ # To make the sampled types useful for checking local changes, we need to be
134
+ # able to know the git commit that produced the sampled types in case they are
135
+ # no longer applicable given local changes (if you changed the callers of the
136
+ # method in question).
137
+ # This will give the git commit on Heroku, though requires this buildpack:
138
+ # https://github.com/ianpurvis/heroku-buildpack-version
139
+ config.git_commit = ENV['SOURCE_VERSION']
140
+ end
141
+ ```
142
+
143
+ You'll also need to set up an endpoint in your app that will serve the sampled
144
+ types (as referenced above with the `sampled_types_url`). Here's an example
145
+ controller (that would need a corresponding route as well):
146
+
147
+ ```
148
+ class SampledTypesController < ApplicationController
149
+ def show
150
+ render json: JSON.pretty_generate(TypeTracer::TypeSampler.sampled_type_info)
151
+ end
152
+ end
153
+ ```
154
+
155
+ ### 3B. Using sampled types to check local changes to a method for undefined method calls on arguments
156
+
157
+ To use the sampled method type signatures from a deployed app, run
158
+ `rake type_tracer:check_arg_sends`. That will fetch the sampled types and Git
159
+ commit hash from your specified `sampled_types_url`. It assumes you are using
160
+ Git for your project and have the `git` executable installed.
161
+
162
+ A similar caveat to the expanded `instance_double` above holds, that the
163
+ checking will assume the method is correct if it either contains branches or a
164
+ re-assignment of the argument variable (because those cases are harder to
165
+ analyze and a common idiom is e.g. `return if x.nil?` or `x ||= default_value`
166
+ both of which could change the type of the `x` argument).
167
+
168
+ ## Installation
169
+
170
+ Add this line to your application's Gemfile:
171
+
172
+ ```ruby
173
+ gem 'type_tracer'
174
+ ```
175
+
176
+ And then execute:
177
+
178
+ $ bundle
179
+
180
+ Or install it yourself as:
181
+
182
+ $ gem install type_tracer
183
+
184
+ ## Development
185
+
186
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
187
+
188
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
189
+
190
+ ## Contributing
191
+
192
+ Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/type_tracer.
193
+
194
+ ## License
195
+
196
+ The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
197
+
data/Rakefile ADDED
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+ require 'bundler/gem_tasks'
3
+ require 'rspec/core/rake_task'
4
+
5
+ RSpec::Core::RakeTask.new(:spec)
6
+
7
+ task default: :spec
data/bin/console ADDED
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'bundler/setup'
5
+ require 'type_tracer'
6
+
7
+ # You can add fixtures and/or initialization code here to make experimenting
8
+ # with your gem easier. You can also use a different console, if you like.
9
+
10
+ # (If you use this, don't forget to add pry to your Gemfile!)
11
+ # require "pry"
12
+ # Pry.start
13
+
14
+ require 'irb'
15
+ IRB.start
data/bin/rspec ADDED
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+ #
4
+ # This file was generated by Bundler.
5
+ #
6
+ # The application 'rspec' is installed as part of a gem, and
7
+ # this file is here to facilitate running it.
8
+ #
9
+
10
+ require 'pathname'
11
+ ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile',
12
+ Pathname.new(__FILE__).realpath)
13
+
14
+ require 'rubygems'
15
+ require 'bundler/setup'
16
+
17
+ load Gem.bin_path('rspec-core', 'rspec')
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypeTracer
4
+ module ArgSendTypeCheck
5
+ class AstChecker
6
+ def initialize(ast:, types:)
7
+ @ast = ast
8
+ @types = types
9
+ end
10
+
11
+ def bad_arg_sends
12
+ method_defs.flat_map(&method(:method_bad_arg_sends))
13
+ end
14
+
15
+ private
16
+
17
+ def method_defs
18
+ @ast.each_descendant.select(&:def_type?)
19
+ end
20
+
21
+ def method_bad_arg_sends(method_def)
22
+ class_sym = method_class_sym(method_def)
23
+ method_sym = method_def.children.first
24
+
25
+ return [] unless @types && @types[class_sym] &&
26
+ @types[class_sym][method_sym]
27
+
28
+ arg_types = @types[class_sym][method_sym][:arg_types]
29
+ MethodChecker.new(method_def: method_def, method_sym: method_sym,
30
+ class_sym: class_sym, arg_types: arg_types)
31
+ .bad_arg_send_messages
32
+ end
33
+
34
+ def method_class_sym(method_def)
35
+ context_ancestors = method_def.each_ancestor.select do |ancestor|
36
+ ancestor.class_type? || ancestor.module_type?
37
+ end
38
+ const_ancestors = context_ancestors.map { |n| n.children.first }
39
+ const_path = const_ancestors.map { |n| n.children[1] }
40
+ const_path.join('::').to_sym
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+ module TypeTracer
3
+ module ArgSendTypeCheck
4
+ class MethodChecker
5
+ def initialize(method_def:, method_sym:, class_sym:, arg_types:)
6
+ @method_def = method_def
7
+ @method_sym = method_sym
8
+ @class_sym = class_sym
9
+ @arg_types = arg_types
10
+ end
11
+
12
+ def bad_arg_send_messages
13
+ # For each argument to the method
14
+ analyzer.arg_names.flat_map(&method(:bad_arg_sends)).compact
15
+ end
16
+
17
+ private
18
+
19
+ def bad_arg_sends(arg)
20
+ # For each of the sampled type classes for that argument
21
+ @arg_types[arg].flat_map do |arg_type|
22
+ bad_arg_sends_for_type(arg, arg_type[0])
23
+ end
24
+ end
25
+
26
+ def bad_arg_sends_for_type(arg, arg_type)
27
+ # Look up the sampled type class. We can do that because this runs with
28
+ # the app environment loaded into it.
29
+ type_class = Object.const_get(arg_type)
30
+
31
+ # For each of the statically-analyzed send calls on that argument
32
+ analyzer.arg_sends[arg].flat_map do |arg_send|
33
+ # Check to see if the send call (in a locally changed method)
34
+ # would be invalid based on the sampled type information (from the
35
+ # method as it is deployed currently).
36
+ next if instance_method?(type_class, arg_send)
37
+ bad_arg_send_message(arg, type_class, arg_send)
38
+ end
39
+ end
40
+
41
+ def analyzer
42
+ @analyzer ||= MethodAnalyzer.new(method_def: @method_def)
43
+ end
44
+
45
+ def bad_arg_send_message(arg_name, arg_type, arg_send)
46
+ source = @method_def.source_range
47
+ "The method #{@class_sym}##{@method_sym} as type sampled may receive a "\
48
+ "value of type #{arg_type} for the argument '#{arg_name}'. "\
49
+ "However, that type (#{arg_type}) does not contain the instance "\
50
+ "method '#{arg_send}' that the method tries to call on it. \n"\
51
+ 'Method location:'\
52
+ "\n #{source.source_buffer.name}:#{source.line}\n"
53
+ end
54
+
55
+ def instance_method?(klass, symbol)
56
+ klass.instance_methods.include?(symbol) ||
57
+ klass.private_instance_methods.include?(symbol)
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypeTracer
4
+ module ArgSendTypeCheck
5
+ class Runner
6
+ def initialize(files)
7
+ @files = files
8
+ end
9
+
10
+ def bad_arg_sends
11
+ @files.flat_map do |file|
12
+ ast = TypeTracer.parse_file(file)
13
+ AstChecker.new(ast: ast, types: filtered_types).bad_arg_sends
14
+ end
15
+ end
16
+
17
+ private
18
+
19
+ def changed_files
20
+ @changed_files ||=
21
+ `git diff --name-only #{fetched_types[:git_commit]}`.split("\n")
22
+ end
23
+
24
+ def fetched_types
25
+ @fetched_types ||= TypeFetcher.fetch_sampled_types
26
+ end
27
+
28
+ def filtered_types
29
+ @filtered_types ||= filter_types
30
+ end
31
+
32
+ def filter_types
33
+ type_info = fetched_types[:type_info]
34
+ type_info.each do |klass_sym, method_types|
35
+ method_types.each do |method_sym, method_type_info|
36
+ # All the callers of this method have at least one file changed, so
37
+ # assume that this method type signature may no longer be valid.
38
+ if all_have_changed_file(method_type_info[:callers])
39
+ # Remove it from the type list
40
+ type_info[klass_sym].delete(method_sym)
41
+ end
42
+ end
43
+ end
44
+ end
45
+
46
+ def all_have_changed_file(call_stacks)
47
+ call_stacks.all? do |call_stack|
48
+ stack_files = call_stack.map { |frame| frame.split(':')[0] }
49
+ !(stack_files & changed_files).empty?
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+ require 'singleton'
3
+
4
+ module TypeTracer
5
+ class Config
6
+ include Singleton
7
+
8
+ attr_accessor :attribute_methods_definer, :type_check_root_path,
9
+ :type_check_path_regex, :git_commit, :sampled_types_url
10
+
11
+ # Set this by giving a block to sample_types_for_requests
12
+ attr_reader :rack_type_sample_decider
13
+
14
+ def initialize
15
+ @type_sampler_root_path = Dir.pwd
16
+ end
17
+
18
+ def sample_types_for_requests(&block)
19
+ @rack_type_sample_decider = block.to_proc
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+ require 'type_tracer/parser'
3
+
4
+ module TypeTracer
5
+ class InstanceMethodChecker
6
+ def initialize(ast)
7
+ @ast = ast
8
+ end
9
+
10
+ def undefined_method_messages
11
+ self_sends.map(&method(:bad_self_send_messages)).compact
12
+ end
13
+
14
+ private
15
+
16
+ def self_sends
17
+ @ast.each_descendant.select do |node|
18
+ node.send_type? && node.children.first.nil?
19
+ end
20
+ end
21
+
22
+ def bad_self_send_messages(send_node)
23
+ symbol = send_node.children[1]
24
+
25
+ # private/protected specifiers are parsed as a send, so just ignore them
26
+ # define_method also doesn't get listed as a method but is one!
27
+ return if [:private, :protected, :define_method].include?(symbol)
28
+
29
+ # Look up the class for the AST send node in the loaded Ruby
30
+ # environment. We can do that because this is being run in a Rake
31
+ # rask with the loaded app environment.
32
+ klass = loaded_class(send_node)
33
+ return if klass.instance_methods.include?(symbol) ||
34
+ klass.private_instance_methods.include?(symbol) ||
35
+ klass.respond_to?(symbol)
36
+
37
+ # The abstract syntax tree keeps a reference to the node's file/line
38
+ source = send_node.source_range
39
+ "Likely undefined method: #{symbol} for #{klass} instance"\
40
+ "\n in #{source.source_buffer.name}:#{source.line}\n\n"
41
+ end
42
+
43
+ def loaded_class(send_node)
44
+ context_ancestors = send_node.each_ancestor.select do |ancestor|
45
+ ancestor.class_type? || ancestor.module_type?
46
+ end
47
+ const_ancestors = context_ancestors.map { |n| n.children.first }
48
+ const_path = const_ancestors.map { |n| n.children[1] }
49
+ Object.const_get(const_path.join('::'))
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+ module TypeTracer
3
+ class MethodAnalyzer
4
+ def initialize(method_def:)
5
+ @method_def = method_def
6
+ end
7
+
8
+ def arg_names
9
+ @arg_names ||= args.children.map { |arg_node| arg_node.children.first }
10
+ end
11
+
12
+ def arg_sends
13
+ return @arg_sends if @arg_sends
14
+ # For the simple version, just assume we don't know what will happen if
15
+ # there are branches in the method, so assume that no args definitely get
16
+ # called (as there could be conditioning on arg type).
17
+ @arg_sends =
18
+ if branches?
19
+ empty_arg_sends
20
+ else
21
+ Hash[arg_names.map { |arg| [arg, sends_for_arg(arg)] }]
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ def branches?
28
+ @method_def.each_descendant.any? { |node| node.if_type? || node.case_type? }
29
+ end
30
+
31
+ def empty_arg_sends
32
+ Hash[arg_names.map { |n| [n, []] }]
33
+ end
34
+
35
+ def sends_for_arg(arg)
36
+ # Assume that if there are any assignments to the local variable for the
37
+ # argument that we can't confidently know the type of the argument anymore
38
+ # even if we know its type coming in, so don't assume we know what sends
39
+ # are made on the initial argument value itself.
40
+ if local_var_assigns.include?(arg)
41
+ []
42
+ else
43
+ local_var_sends[arg].to_a
44
+ end
45
+ end
46
+
47
+ def args
48
+ @method_def.each_descendant.find(&:args_type?)
49
+ end
50
+
51
+ def local_var_sends
52
+ return @local_var_sends if @local_var_sends
53
+ @local_var_sends = {}
54
+
55
+ local_var_send_nodes.map do |send_node|
56
+ object, method_sym = send_node.children[0..1]
57
+ local_var = object.children.first
58
+ @local_var_sends[local_var] ||= Set.new
59
+ @local_var_sends[local_var] << method_sym
60
+ end
61
+
62
+ @local_var_sends
63
+ end
64
+
65
+ def local_var_assigns
66
+ return @local_var_assigns if @local_var_assigns
67
+ @local_var_assigns = Set.new
68
+ @method_def.each_descendant do |node|
69
+ @local_var_assigns << node.children.first if node.lvasgn_type?
70
+ end
71
+ @local_var_assigns
72
+ end
73
+
74
+ def local_var_send_nodes
75
+ @method_def.each_descendant.select do |node|
76
+ node.children && node.children.first && node.send_type? &&
77
+ node.children.first.lvar_type?
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+ require 'rubocop'
3
+ require 'parser/current'
4
+ Parser::Builders::Default.emit_lambda = true
5
+
6
+ module TypeTracer
7
+ class << self
8
+ def parse_file(file)
9
+ parse(File.read(file), file)
10
+ end
11
+
12
+ def parse(source, filename = '(string)')
13
+ buffer = Parser::Source::Buffer.new(filename)
14
+ buffer.source = source
15
+
16
+ builder = RuboCop::Node::Builder.new
17
+ parser = Parser::CurrentRuby.new(builder)
18
+ parser.parse(buffer)
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+ require 'type_tracer/type_sampler'
3
+
4
+ module TypeTracer
5
+ class TypeSamplerMiddleware
6
+ def initialize(app)
7
+ @app = app
8
+ end
9
+
10
+ def call(env)
11
+ sample_decider = TypeTracer.config.rack_type_sample_decider
12
+ TypeSampler.start if sample_decider && sample_decider.call(env)
13
+ @app.call(env)
14
+ ensure
15
+ TypeSampler.stop
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+ # rake tasks for Rails 3+
3
+ module TypeTracer
4
+ class Railtie < ::Rails::Railtie
5
+ rake_tasks do
6
+ require 'type_tracer/rake/tasks'
7
+ end
8
+
9
+ initializer 'type_tracer.insert_middleware' do |app|
10
+ require 'type_tracer/rack/type_sampler_middleware.rb'
11
+ app.config.middleware.use 'TypeTracer::TypeSamplerMiddleware'
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+ require 'type_tracer'
3
+
4
+ desc 'Checks for undefined instance methods'
5
+ namespace :type_tracer do
6
+ task check_method_calls: :environment do |_, _args|
7
+ root_dir = defined?(Rails) ? Rails.root : '.'
8
+ ruby_files = Dir.glob(root_dir.join('app/**/*.rb'))
9
+ ruby_files.each { |file| load(file) }
10
+
11
+ # Call the configured attribute methods definer to define
12
+ # methods on classes, e.g. initialize ActiveRecord models
13
+ TypeTracer.config.attribute_methods_definer.try(:call)
14
+
15
+ exit(1) unless undefined_method_messages(ruby_files).empty?
16
+ end
17
+
18
+ def undefined_method_messages(ruby_files)
19
+ ruby_files.flat_map do |file|
20
+ ast = TypeTracer.parse_file(file)
21
+ messages = TypeTracer::InstanceMethodChecker.new(ast).undefined_method_messages
22
+ messages.each(&method(:puts))
23
+ messages
24
+ end
25
+ end
26
+
27
+ task check_arg_sends: :environment do |_, _args|
28
+ root_dir = TypeTracer.config.type_check_root_path.to_s + '/'
29
+ files = Dir.glob(File.join(root_dir, '**/*.rb'))
30
+ TypeTracer.config.type_check_path_regex = %r{\A(app|lib)/}
31
+ type_check_files = files.select do |file|
32
+ project_path = file[root_dir.size..-1]
33
+ project_path =~ TypeTracer.config.type_check_path_regex
34
+ end
35
+
36
+ type_check_files.each { |file| load(file) }
37
+
38
+ runner = TypeTracer::ArgSendTypeCheck::Runner.new(type_check_files)
39
+ bad_arg_sends = runner.bad_arg_sends
40
+ bad_arg_sends.each { |message| puts message }
41
+ exit(1) if bad_arg_sends.present?
42
+ end
43
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+ module TypeTracer
3
+ class RealArgSendsChecker
4
+ def initialize(klass, symbol, args)
5
+ @klass = klass
6
+ @symbol = symbol
7
+ @args = args
8
+ end
9
+
10
+ def invalid_arg_messages
11
+ @args.each_with_index.flat_map(&method(:message_if_invalid_arg)).compact
12
+ end
13
+
14
+ private
15
+
16
+ # arg_value is the real argument value that we would be calling this method
17
+ # with (based on the method call on the stub).
18
+ # index is the argument's position in the call
19
+ def message_if_invalid_arg(arg_value, index)
20
+ arg_sends = method_analyzer.arg_sends.values[index]
21
+ return unless arg_sends
22
+
23
+ # arg_sends is the list of definite method calls on that argument that
24
+ # the static analyzer detected. If there are branches in the method or
25
+ # assignments to that argument in the method, the analyzer is conservative
26
+ # and doesn't assume that the sends will necessarily be on this value.
27
+ arg_sends.each do |arg_send|
28
+ next if arg_value.respond_to?(arg_send)
29
+
30
+ # Give an invalid arg message if the arg doesn't respond to the call
31
+ # that the stubbed method would make on it.
32
+ return invalid_arg_message(arg_value, arg_send, index)
33
+ end
34
+ nil
35
+ end
36
+
37
+ def invalid_arg_message(arg_value, arg_send, index)
38
+ "Called stubbed method #{@klass}##{@symbol} with unrealistic "\
39
+ "'#{method_analyzer.arg_names[index]}' argument: #{arg_value.inspect}. "\
40
+ "It should respond to #{arg_send.inspect} but it does not."
41
+ end
42
+
43
+ def method_analyzer
44
+ @method_analyzer ||= MethodAnalyzer.new(method_def: method_ast)
45
+ end
46
+
47
+ def method_ast
48
+ @method_ast ||=
49
+ begin
50
+ file, _line = @klass.instance_method(@symbol).source_location
51
+ ast = TypeTracer.parse_file(file)
52
+ find_method_def(ast, @symbol)
53
+ end
54
+ end
55
+
56
+ def find_method_def(ast, method_symbol)
57
+ ast.each_descendant.find do |node|
58
+ node.def_type? && node.children.first == method_symbol
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+ require 'rspec/mocks'
3
+ require 'type_tracer'
4
+ require 'type_tracer/real_arg_sends_checker'
5
+
6
+ module RSpec
7
+ module Mocks
8
+ class VerifyingMethodDouble
9
+ alias orig_proxy_method_invoked proxy_method_invoked
10
+
11
+ def proxy_method_invoked(obj, *args, &block)
12
+ orig_proxy_method_invoked(obj, *args, &block)
13
+ return unless obj.is_a?(InstanceVerifyingDouble)
14
+
15
+ name = @method_reference.instance_variable_get('@method_name')
16
+ klass = @method_reference.instance_variable_get('@object_reference')
17
+ .instance_variable_get('@object')
18
+
19
+ checker = TypeTracer::RealArgSendsChecker.new(klass, name, args)
20
+ messages = checker.invalid_arg_messages
21
+ raise MockExpectationError.new, messages.join("\n") unless messages.empty?
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+ class SendsWatcher < BasicObject
3
+ def initialize(target, calls_list)
4
+ @target = target
5
+ @calls_list = calls_list
6
+ end
7
+
8
+ def method_missing(m, *args, &block)
9
+ @calls_list << m unless @calls_list.include?(m)
10
+ @target.send(m, *args, &block)
11
+ end
12
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+ require 'net/http'
3
+ require 'json'
4
+ require 'fileutils'
5
+
6
+ module TypeTracer
7
+ class TypeFetcher
8
+ class << self
9
+ def fetch_sampled_types
10
+ url = TypeTracer.config.sampled_types_url
11
+ puts "Using sampled types from: #{url}\n\n"
12
+ json = Net::HTTP.get(URI.parse(url))
13
+ save_types_locally(json)
14
+ JSON.parse(json).deep_symbolize_keys
15
+ end
16
+
17
+ def save_types_locally(types_json)
18
+ root_path = TypeTracer.config.type_check_root_path
19
+ folder = File.join(root_path, 'tmp', 'type_tracer')
20
+ FileUtils.mkdir_p(folder)
21
+ file = File.join(folder, 'sampled_types.json')
22
+ File.write(file, types_json)
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+ require 'type_tracer/sends_watcher'
3
+
4
+ module TypeTracer
5
+ class TypeSampler
6
+ class << self
7
+ def start
8
+ @project_root ||= TypeTracer.config.type_check_root_path.to_s + '/'
9
+ @sample_path_regex ||= TypeTracer.config.type_check_path_regex
10
+ @ignored_classes ||= Set.new
11
+ @type_info_by_class ||= {}
12
+ @trace ||= TracePoint.new(:call, &method(:trace_method_call))
13
+ @trace.enable
14
+ end
15
+
16
+ def stop
17
+ return unless @trace && @trace.enabled?
18
+ @trace.disable
19
+ end
20
+
21
+ def sampled_type_info
22
+ {
23
+ git_commit: TypeTracer.config.git_commit,
24
+ type_info: @type_info_by_class
25
+ }
26
+ end
27
+
28
+ def clear_sampled_type_info
29
+ @type_info_by_class = {}
30
+ end
31
+
32
+ private
33
+
34
+ def trace_method_call(tp)
35
+ klass = tp.defined_class
36
+
37
+ # Skip if the method call is in this class or an ignored class
38
+ return if klass == self.class || @ignored_classes.include?(klass)
39
+
40
+ unbound_method = unbound_method_or_nil(tp)
41
+ return unless unbound_method
42
+
43
+ if in_sample_path?(unbound_method.source_location[0])
44
+ add_sampled_type_info(tp, unbound_method)
45
+ else
46
+ @ignored_classes << klass
47
+ end
48
+ end
49
+
50
+ def in_sample_path?(path)
51
+ return false unless path.start_with?(@project_root)
52
+ path[@project_root.size..-1] =~ @sample_path_regex
53
+ end
54
+
55
+ def unbound_method_or_nil(tp)
56
+ tp.defined_class.instance_method(tp.method_id)
57
+ rescue
58
+ # Return nil if the defined class fails to provide `instance_method`
59
+ nil
60
+ end
61
+
62
+ def add_sampled_type_info(tp, unbound_method)
63
+ method_info = find_method_info(tp, unbound_method)
64
+
65
+ add_project_call_stack(method_info[:callers])
66
+
67
+ arg_names = method_info[:args].map { |a| a[1] }
68
+ add_args_type_info(tp, method_info[:arg_types], arg_names)
69
+ end
70
+
71
+ def find_method_info(tp, unbound_method)
72
+ klass = tp.defined_class
73
+ @type_info_by_class[klass] ||= {}
74
+ class_type_info = @type_info_by_class[klass]
75
+ class_type_info[tp.method_id] ||= default_method_info(unbound_method)
76
+ end
77
+
78
+ def default_method_info(unbound_method)
79
+ { args: unbound_method.parameters, arg_types: {}, callers: [] }
80
+ end
81
+
82
+ def add_project_call_stack(call_stacks)
83
+ # Exclude non-project frames, and then also exclude the first project
84
+ # frame as that frame is for the method call we are type sampling.
85
+ stack = caller.select(&method(:in_sample_path?))[1..-1]
86
+ .map { |f| f[@project_root.size..-1] }
87
+ call_stacks << stack unless call_stacks.include?(stack)
88
+ end
89
+
90
+ def add_args_type_info(tp, args_type_info, arg_names)
91
+ arg_local_vars = arg_names & tp.binding.local_variables
92
+
93
+ arg_local_vars.each do |arg|
94
+ args_type_info[arg] ||= {}
95
+ add_arg_type_info(tp, args_type_info[arg], arg)
96
+ end
97
+ end
98
+
99
+ def add_arg_type_info(tp, arg_type_info, arg)
100
+ value = tp.binding.local_variable_get(arg)
101
+ value_klass = value.class
102
+
103
+ arg_type_info[value_klass] ||= []
104
+
105
+ # We can only do do a delegate-based type watching on truthy
106
+ # values because it's not possible to turn a custom object into a
107
+ # falsely value in Ruby
108
+ return unless value && !value.is_a?(Fixnum)
109
+ watcher = SendsWatcher.new(value, arg_type_info[value_klass])
110
+ tp.binding.local_variable_set(arg, watcher)
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+ module TypeTracer
3
+ VERSION = '0.1.0'
4
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+ Dir[File.join(File.dirname(__FILE__), 'type_tracer', '*.rb')].each do |file|
3
+ next if file =~ /version/
4
+ require File.join('type_tracer', File.basename(file, '.rb'))
5
+ end
6
+ require 'type_tracer/parser'
7
+ require 'type_tracer/arg_send_type_check/ast_checker'
8
+ require 'type_tracer/arg_send_type_check/method_checker'
9
+ require 'type_tracer/arg_send_type_check/runner'
10
+ require 'type_tracer/instance_method_checker'
11
+ require 'type_tracer/config'
12
+ require 'type_tracer/method_analyzer'
13
+
14
+ module TypeTracer
15
+ class << self
16
+ def config
17
+ @config ||= TypeTracer::Config.instance
18
+ yield @config if block_given?
19
+ @config
20
+ end
21
+ end
22
+ end
23
+
24
+ require 'type_tracer/rails/railtie' if defined? Rails::Railtie
@@ -0,0 +1,33 @@
1
+ # coding: utf-8
2
+ # frozen_string_literal: true
3
+ lib = File.expand_path('../lib', __FILE__)
4
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
5
+ require 'type_tracer/version'
6
+
7
+ Gem::Specification.new do |spec|
8
+ spec.name = 'type_tracer'
9
+ spec.version = TypeTracer::VERSION
10
+ spec.authors = ['draffensperger']
11
+ spec.email = ['draff8660@gmail.com']
12
+
13
+ spec.summary = 'Proof of concept tool for Ruby static/dynamic analysis'
14
+ spec.description = 'Proof of concept tool for Ruby static/dynamic analysis'
15
+ spec.homepage = 'https://github.com/draffensperger/type_tracer'
16
+ spec.license = 'MIT'
17
+
18
+ spec.files = `git ls-files -z`.split("\x0")
19
+ .reject { |f| f.match(%r{^(test|spec|features)/}) }
20
+ spec.bindir = 'exe'
21
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
22
+ spec.require_paths = ['lib']
23
+
24
+ spec.add_runtime_dependency('parser', '>= 2.3.0.6', '< 3.0')
25
+ spec.add_runtime_dependency('activesupport', '~> 4.2.6')
26
+ spec.add_runtime_dependency('rubocop', '~> 0.39')
27
+
28
+ spec.add_development_dependency 'simplecov', '~> 0.11.2'
29
+ spec.add_development_dependency 'bundler', '~> 1.11'
30
+ spec.add_development_dependency 'rake', '~> 10.0'
31
+ spec.add_development_dependency 'rspec', '~> 3.0'
32
+ spec.add_development_dependency 'pry-byebug'
33
+ end
metadata ADDED
@@ -0,0 +1,191 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: type_tracer
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - draffensperger
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2016-05-09 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: parser
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: 2.3.0.6
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: '3.0'
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ version: 2.3.0.6
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: '3.0'
33
+ - !ruby/object:Gem::Dependency
34
+ name: activesupport
35
+ requirement: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: 4.2.6
40
+ type: :runtime
41
+ prerelease: false
42
+ version_requirements: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: 4.2.6
47
+ - !ruby/object:Gem::Dependency
48
+ name: rubocop
49
+ requirement: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '0.39'
54
+ type: :runtime
55
+ prerelease: false
56
+ version_requirements: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '0.39'
61
+ - !ruby/object:Gem::Dependency
62
+ name: simplecov
63
+ requirement: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: 0.11.2
68
+ type: :development
69
+ prerelease: false
70
+ version_requirements: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: 0.11.2
75
+ - !ruby/object:Gem::Dependency
76
+ name: bundler
77
+ requirement: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '1.11'
82
+ type: :development
83
+ prerelease: false
84
+ version_requirements: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '1.11'
89
+ - !ruby/object:Gem::Dependency
90
+ name: rake
91
+ requirement: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: '10.0'
96
+ type: :development
97
+ prerelease: false
98
+ version_requirements: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - "~>"
101
+ - !ruby/object:Gem::Version
102
+ version: '10.0'
103
+ - !ruby/object:Gem::Dependency
104
+ name: rspec
105
+ requirement: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - "~>"
108
+ - !ruby/object:Gem::Version
109
+ version: '3.0'
110
+ type: :development
111
+ prerelease: false
112
+ version_requirements: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - "~>"
115
+ - !ruby/object:Gem::Version
116
+ version: '3.0'
117
+ - !ruby/object:Gem::Dependency
118
+ name: pry-byebug
119
+ requirement: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - ">="
122
+ - !ruby/object:Gem::Version
123
+ version: '0'
124
+ type: :development
125
+ prerelease: false
126
+ version_requirements: !ruby/object:Gem::Requirement
127
+ requirements:
128
+ - - ">="
129
+ - !ruby/object:Gem::Version
130
+ version: '0'
131
+ description: Proof of concept tool for Ruby static/dynamic analysis
132
+ email:
133
+ - draff8660@gmail.com
134
+ executables: []
135
+ extensions: []
136
+ extra_rdoc_files: []
137
+ files:
138
+ - ".gitignore"
139
+ - ".rspec"
140
+ - ".rubocop.yml"
141
+ - ".travis.yml"
142
+ - Gemfile
143
+ - LICENSE.txt
144
+ - README.md
145
+ - Rakefile
146
+ - bin/console
147
+ - bin/rspec
148
+ - bin/setup
149
+ - lib/type_tracer.rb
150
+ - lib/type_tracer/arg_send_type_check/ast_checker.rb
151
+ - lib/type_tracer/arg_send_type_check/method_checker.rb
152
+ - lib/type_tracer/arg_send_type_check/runner.rb
153
+ - lib/type_tracer/config.rb
154
+ - lib/type_tracer/instance_method_checker.rb
155
+ - lib/type_tracer/method_analyzer.rb
156
+ - lib/type_tracer/parser.rb
157
+ - lib/type_tracer/rack/type_sampler_middleware.rb
158
+ - lib/type_tracer/rails/railtie.rb
159
+ - lib/type_tracer/rake/tasks.rb
160
+ - lib/type_tracer/real_arg_sends_checker.rb
161
+ - lib/type_tracer/rspec/instance_double_arg_checker.rb
162
+ - lib/type_tracer/sends_watcher.rb
163
+ - lib/type_tracer/type_fetcher.rb
164
+ - lib/type_tracer/type_sampler.rb
165
+ - lib/type_tracer/version.rb
166
+ - type_tracer.gemspec
167
+ homepage: https://github.com/draffensperger/type_tracer
168
+ licenses:
169
+ - MIT
170
+ metadata: {}
171
+ post_install_message:
172
+ rdoc_options: []
173
+ require_paths:
174
+ - lib
175
+ required_ruby_version: !ruby/object:Gem::Requirement
176
+ requirements:
177
+ - - ">="
178
+ - !ruby/object:Gem::Version
179
+ version: '0'
180
+ required_rubygems_version: !ruby/object:Gem::Requirement
181
+ requirements:
182
+ - - ">="
183
+ - !ruby/object:Gem::Version
184
+ version: '0'
185
+ requirements: []
186
+ rubyforge_project:
187
+ rubygems_version: 2.6.4
188
+ signing_key:
189
+ specification_version: 4
190
+ summary: Proof of concept tool for Ruby static/dynamic analysis
191
+ test_files: []