type_tracer 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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: []