churn 0.0.2

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,5 @@
1
+ README.rdoc
2
+ lib/**/*.rb
3
+ bin/*
4
+ features/**/*.feature
5
+ LICENSE
@@ -0,0 +1,6 @@
1
+ *.sw?
2
+ .DS_Store
3
+ coverage
4
+ rdoc
5
+ pkg
6
+ tmp
data/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2009 Dan Mayer
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,32 @@
1
+ = churn
2
+
3
+ A Project to give the churn file, class, and method for a project for a given checkin
4
+ This will allow us over time to give the number of times a file, class, or method is changing during the life of a project.
5
+
6
+ TODO:
7
+ * SVN only supports file
8
+ * make storage directory configurable instead of using tmp
9
+ * allow passing in directories to churn, directories to ignore
10
+ * add a bin/ with args passed as well as just the rake task
11
+ * todo add a filter that allows for other files besides. *.rb
12
+
13
+ Usage:
14
+ * 'gem install churn'
15
+ * on any project you want to use churn, add "require 'churn'" to your rake file
16
+ * run 'rake churn' to view the current output, file churn history is immediate, class and method churn builds up a history as it is run on each revision
17
+ * temporary files with class / method churn history are stored in /tmp, to clear churn history delete them
18
+
19
+ == Note on Patches/Pull Requests
20
+
21
+ * Fork the project.
22
+ * Make your feature addition or bug fix.
23
+ * Add tests for it. This is important so I don't break it in a
24
+ future version unintentionally.
25
+ * Commit, do not mess with rakefile, version, or history.
26
+ (if you want to have your own version, that is fine but
27
+ bump version in a commit by itself I can ignore when I pull)
28
+ * Send me a pull request. Bonus points for topic branches.
29
+
30
+ == Copyright
31
+
32
+ Copyright (c) 2009 Dan Mayer. See LICENSE for details.
@@ -0,0 +1,57 @@
1
+ require 'rubygems'
2
+ require 'rake'
3
+ require 'lib/tasks/churn_tasks'
4
+
5
+ begin
6
+ require 'jeweler'
7
+ Jeweler::Tasks.new do |gem|
8
+ gem.name = "churn"
9
+ gem.summary = %Q{Providing additional churn metrics over the original metric_fu churn}
10
+ gem.description = %Q{High method and class churn has been shown to have increased bug and error rates. This gem helps you know what is changing a lot so you can do additional testing, code review, or refactoring to try to tame the volatile code. }
11
+ gem.email = "Danmayer@gmail.com"
12
+ gem.homepage = "http://github.com/danmayer/churn"
13
+ gem.authors = ["Dan Mayer"]
14
+ gem.add_development_dependency "thoughtbot-shoulda"
15
+ # gem is a Gem::Specification... see http://www.rubygems.org/read/chapter/20 for additional settings
16
+ end
17
+ rescue LoadError
18
+ puts "Jeweler (or a dependency) not available. Install it with: sudo gem install jeweler"
19
+ end
20
+
21
+ require 'rake/testtask'
22
+ Rake::TestTask.new(:test) do |test|
23
+ test.libs << 'lib' << 'test'
24
+ test.pattern = 'test/**/*_test.rb'
25
+ test.verbose = true
26
+ end
27
+
28
+ begin
29
+ require 'rcov/rcovtask'
30
+ Rcov::RcovTask.new do |test|
31
+ test.libs << 'test'
32
+ test.pattern = 'test/**/*_test.rb'
33
+ test.verbose = true
34
+ end
35
+ rescue LoadError
36
+ task :rcov do
37
+ abort "RCov is not available. In order to run rcov, you must: sudo gem install spicycode-rcov"
38
+ end
39
+ end
40
+
41
+ task :test => :check_dependencies
42
+
43
+ task :default => :test
44
+
45
+ require 'rake/rdoctask'
46
+ Rake::RDocTask.new do |rdoc|
47
+ if File.exist?('VERSION')
48
+ version = File.read('VERSION')
49
+ else
50
+ version = ""
51
+ end
52
+
53
+ rdoc.rdoc_dir = 'rdoc'
54
+ rdoc.title = "churn #{version}"
55
+ rdoc.rdoc_files.include('README*')
56
+ rdoc.rdoc_files.include('lib/**/*.rb')
57
+ end
data/VERSION ADDED
@@ -0,0 +1 @@
1
+ 0.0.2
@@ -0,0 +1 @@
1
+ require File.join(File.dirname(__FILE__), 'tasks', 'churn_tasks')
@@ -0,0 +1,209 @@
1
+ require 'chronic'
2
+ require 'sexp_processor'
3
+ require 'ruby_parser'
4
+ require 'json'
5
+ require 'fileutils'
6
+
7
+ $LOAD_PATH.unshift(File.dirname(__FILE__))
8
+ require 'source_control'
9
+ require 'git_analyzer'
10
+ require 'svn_analyzer'
11
+ require 'location_mapping'
12
+ require 'churn_history'
13
+
14
+ module Churn
15
+
16
+ class ChurnCalculator
17
+
18
+ def initialize(options={})
19
+ start_date = options.fetch(:start_date) { '3 months ago' }
20
+ @minimum_churn_count = options.fetch(:minimum_churn_count) { 5 }
21
+ @source_control = set_source_control(start_date)
22
+ @revision_changes = {}
23
+ @method_changes = {}
24
+ @class_changes = {}
25
+ end
26
+
27
+ def report(print = true)
28
+ self.emit
29
+ self.analyze
30
+ print ? self.to_s : self.to_h
31
+ end
32
+
33
+ def emit
34
+ @changes = parse_log_for_changes.reject {|file, change_count| change_count < @minimum_churn_count}
35
+ @revisions = parse_log_for_revision_changes
36
+ end
37
+
38
+ def analyze
39
+ @changes = @changes.to_a.sort {|x,y| y[1] <=> x[1]}
40
+ @changes = @changes.map {|file_path, times_changed| {:file_path => file_path, :times_changed => times_changed }}
41
+
42
+ calculate_revision_changes
43
+
44
+ @method_changes = @method_changes.to_a.sort {|x,y| y[1] <=> x[1]}
45
+ @method_changes = @method_changes.map {|method, times_changed| {'method' => method, 'times_changed' => times_changed }}
46
+ @class_changes = @class_changes.to_a.sort {|x,y| y[1] <=> x[1]}
47
+ @class_changes = @class_changes.map {|klass, times_changed| {'klass' => klass, 'times_changed' => times_changed }}
48
+ end
49
+
50
+ def to_h
51
+ hash = {:churn => {:changes => @changes}}
52
+ hash[:churn][:class_churn] = @class_changes
53
+ hash[:churn][:method_churn] = @method_changes
54
+ #detail the most recent changes made this revision
55
+ if @revision_changes[@revisions.first]
56
+ changes = @revision_changes[@revisions.first]
57
+ hash[:churn][:changed_files] = changes[:files]
58
+ hash[:churn][:changed_classes] = changes[:classes]
59
+ hash[:churn][:changed_methods] = changes[:methods]
60
+ end
61
+ #TODO crappy place to do this but save hash to revision file but while entirely under metric_fu only choice
62
+ ChurnHistory.store_revision_history(@revisions.first, hash)
63
+ hash
64
+ end
65
+
66
+ def to_s
67
+ hash = to_h
68
+ result = seperator
69
+ result +="* Revision Changes \n"
70
+ result += seperator
71
+ result += "files: \n"
72
+ result += display_array(hash[:churn][:changed_files])
73
+ result += "\nclasses: \n"
74
+ result += display_array(hash[:churn][:changed_classes])
75
+ result += "\nmethods: \n"
76
+ result += display_array(hash[:churn][:changed_methods])
77
+ result += seperator
78
+ result +="* Project Churn \n"
79
+ result += seperator
80
+ result += "files: \n"
81
+ result += display_array(hash[:churn][:changes])
82
+ result += "\nclasses: \n"
83
+ result += display_array(hash[:churn][:class_churn])
84
+ result += "\nmethods: \n"
85
+ result += display_array(hash[:churn][:method_churn])
86
+ end
87
+
88
+ private
89
+
90
+ def display_array(array)
91
+ result = ""
92
+ array.each { |element| result += " * #{element.inspect}\n" } if array
93
+ result
94
+ end
95
+
96
+ def seperator
97
+ "*"*70+"\n"
98
+ end
99
+
100
+ def self.git?
101
+ system("git branch")
102
+ end
103
+
104
+ def set_source_control(start_date)
105
+ if self.class.git?
106
+ GitAnalyzer.new(start_date)
107
+ elsif File.exist?(".svn")
108
+ SvnAnalyzer.new(start_date)
109
+ else
110
+ raise "Churning requires a subversion or git repo"
111
+ end
112
+ end
113
+
114
+ def calculate_revision_changes
115
+ @revisions.each do |revision|
116
+ if revision == @revisions.first
117
+ #can't iterate through all the changes and tally them up
118
+ #it only has the current files not the files at the time of the revision
119
+ #parsing requires the files
120
+ changed_files, changed_classes, changed_methods = calculate_revision_data(revision)
121
+ else
122
+ changed_files, changed_classes, changed_methods = ChurnHistory.load_revision_data(revision)
123
+ end
124
+ calculate_changes!(changed_methods, @method_changes) if changed_methods
125
+ calculate_changes!(changed_classes, @class_changes) if changed_classes
126
+
127
+ @revision_changes[revision] = { :files => changed_files, :classes => changed_classes, :methods => changed_methods }
128
+ end
129
+ end
130
+
131
+ def calculate_revision_data(revision)
132
+ changed_files = parse_logs_for_updated_files(revision, @revisions)
133
+
134
+ changed_classes = []
135
+ changed_methods = []
136
+ changed_files.each do |file_changes|
137
+ if file_changes.first.match(/.*\.rb/)
138
+ classes, methods = get_changes(file_changes)
139
+ changed_classes += classes
140
+ changed_methods += methods
141
+ end
142
+ end
143
+ changed_files = changed_files.map { |file, lines| file }
144
+ [changed_files, changed_classes, changed_methods]
145
+ end
146
+
147
+ def calculate_changes!(changed_objs, total_changes)
148
+ if changed_objs
149
+ changed_objs.each do |change|
150
+ total_changes.include?(change) ? total_changes[change] = total_changes[change]+1 : total_changes[change] = 1
151
+ end
152
+ end
153
+ total_changes
154
+ end
155
+
156
+ def get_changes(change)
157
+ file = change.first
158
+ breakdown = LocationMapping.new
159
+ breakdown.get_info(file)
160
+ changes = change.last
161
+ classes = changes_for_type(changes, breakdown.klasses_collection)
162
+ methods = changes_for_type(changes, breakdown.methods_collection)
163
+ classes = classes.map{ |klass| {'file' => file, 'klass' => klass} }
164
+ methods = methods.map{ |method| {'file' => file, 'klass' => get_klass_for(method), 'method' => method} }
165
+ [classes, methods]
166
+ rescue => error
167
+ [[],[]]
168
+ end
169
+
170
+ def get_klass_for(method)
171
+ method.gsub(/(#|\.).*/,'')
172
+ end
173
+
174
+ def changes_for_type(changes, item_collection)
175
+ changed_items = []
176
+ item_collection.each_pair do |item, item_lines|
177
+ item_lines = item_lines[0].to_a
178
+ changes.each do |change_range|
179
+ item_lines.each do |line|
180
+ changed_items << item if change_range.include?(line) && !changed_items.include?(item)
181
+ end
182
+ end
183
+ end
184
+ changed_items
185
+ end
186
+
187
+ def parse_log_for_changes
188
+ changes = {}
189
+
190
+ logs = @source_control.get_logs
191
+ logs.each do |line|
192
+ changes[line] ? changes[line] += 1 : changes[line] = 1
193
+ end
194
+ changes
195
+ end
196
+
197
+ def parse_log_for_revision_changes
198
+ @source_control.get_revisions
199
+ end
200
+
201
+ def parse_logs_for_updated_files(revision, revisions)
202
+ #SVN doesn't support this
203
+ return {} unless @source_control.respond_to?(:get_updated_files_change_info)
204
+ @source_control.get_updated_files_change_info(revision, revisions)
205
+ end
206
+
207
+ end
208
+
209
+ end
@@ -0,0 +1,25 @@
1
+ module Churn
2
+
3
+ class ChurnHistory
4
+
5
+ def self.store_revision_history(revision, hash_data)
6
+ FileUtils.mkdir 'tmp' unless File.directory?('tmp')
7
+ File.open("tmp/#{revision}.json", 'w') {|f| f.write(hash_data.to_json) }
8
+ end
9
+
10
+ def self.load_revision_data(revision)
11
+ #load revision data from scratch folder if it exists
12
+ filename = "tmp/#{revision}.json"
13
+ if File.exists?(filename)
14
+ json_data = File.read(filename)
15
+ data = JSON.parse(json_data)
16
+ changed_files = data['churn']['changed_files']
17
+ changed_classes = data['churn']['changed_classes']
18
+ changed_methods = data['churn']['changed_methods']
19
+ end
20
+ [changed_files, changed_classes, changed_methods]
21
+ end
22
+
23
+ end
24
+
25
+ end
@@ -0,0 +1,73 @@
1
+ module Churn
2
+
3
+ class GitAnalyzer < SourceControl
4
+ def get_logs
5
+ `git log #{date_range} --name-only --pretty=format:`.split(/\n/).reject{|line| line == ""}
6
+ end
7
+
8
+ def get_revisions
9
+ `git log #{date_range} --pretty=format:"%H"`.split(/\n/).reject{|line| line == ""}
10
+ end
11
+
12
+ def get_updated_files_from_log(revision, revisions)
13
+ current_index = revisions.index(revision)
14
+ previous_index = current_index+1
15
+ previous_revision = revisions[previous_index] unless revisions.length < previous_index
16
+ if revision && previous_revision
17
+ `git diff #{revision} #{previous_revision} --unified=0`.split(/\n/).select{|line| line.match(/^@@/) || line.match(/^---/) || line.match(/^\+\+\+/) }
18
+ else
19
+ []
20
+ end
21
+ end
22
+
23
+ def get_updated_files_change_info(revision, revisions)
24
+ updated = {}
25
+ logs = get_updated_files_from_log(revision, revisions)
26
+ recent_file = nil
27
+ logs.each do |line|
28
+ if line.match(/^---/) || line.match(/^\+\+\+/)
29
+ recent_file = get_recent_file(line)
30
+ updated[recent_file] = [] unless updated.include?(recent_file)
31
+ elsif line.match(/^@@/)
32
+ removed_range = get_changed_range(line, '-')
33
+ added_range = get_changed_range(line, '\+')
34
+ updated[recent_file] << removed_range
35
+ updated[recent_file] << added_range
36
+ else
37
+ puts line.match(/^---/)
38
+ raise "git diff lines that don't match the two patterns aren't expected: '#{line}'"
39
+ end
40
+ end
41
+ updated
42
+ end
43
+
44
+ private
45
+
46
+ def get_changed_range(line, matcher)
47
+ change_start = line.match(/#{matcher}[0-9]+/)
48
+ change_end = line.match(/#{matcher}[0-9]+,[0-9]+/)
49
+ change_start = change_start.to_s.gsub(/#{matcher}/,'')
50
+ change_end = change_end.to_s.gsub(/.*,/,'')
51
+
52
+ range = if change_end && change_end!=''
53
+ (change_start.to_i..(change_start.to_i+change_end.to_i))
54
+ else
55
+ (change_start.to_i..change_start.to_i)
56
+ end
57
+ range
58
+ end
59
+
60
+ def get_recent_file(line)
61
+ line = line.gsub(/^--- /,'').gsub(/^\+\+\+ /,'').gsub(/^a\//,'').gsub(/^b\//,'')
62
+ end
63
+
64
+ def date_range
65
+ if @start_date
66
+ date = Chronic.parse(@start_date)
67
+ "--after=#{date.strftime('%Y-%m-%d')}"
68
+ end
69
+ end
70
+
71
+ end
72
+
73
+ end
@@ -0,0 +1,49 @@
1
+ module Churn
2
+
3
+ class LocationMapping < SexpProcessor
4
+
5
+ attr_reader :klasses_collection, :methods_collection
6
+
7
+ def initialize()
8
+ super
9
+ @klasses_collection = {}
10
+ @methods_collection = {}
11
+ @parser = RubyParser.new
12
+ self.auto_shift_type = true
13
+ end
14
+
15
+ def get_info(file)
16
+ ast = @parser.process(File.read(file), file)
17
+ process ast
18
+ end
19
+
20
+ def process_class(exp)
21
+ name = exp.shift
22
+ start_line = exp.line
23
+ last_line = exp.last.line
24
+ name = name if name.is_a?(Symbol)
25
+ name = name.values.value if name.is_a?(Sexp) #deals with cases like class Test::Unit::TestCase
26
+ @current_class = name
27
+ @klasses_collection[name.to_s] = [] unless @klasses_collection.include?(name)
28
+ @klasses_collection[name.to_s] << (start_line..last_line)
29
+ analyze_list exp
30
+ s()
31
+ end
32
+
33
+ def analyze_list exp
34
+ process exp.shift until exp.empty?
35
+ end
36
+
37
+ def process_defn(exp)
38
+ name = exp.shift
39
+ start_line = exp.line
40
+ last_line = exp.last.line
41
+ full_name = "#{@current_class}##{name}"
42
+ @methods_collection[full_name] = [] unless @methods_collection.include?(full_name)
43
+ @methods_collection[full_name] << (start_line..last_line)
44
+ return s(:defn, name, process(exp.shift), process(exp.shift))
45
+ end
46
+
47
+ end
48
+
49
+ end
@@ -0,0 +1,9 @@
1
+ module Churn
2
+
3
+ class SourceControl
4
+ def initialize(start_date=nil)
5
+ @start_date = start_date
6
+ end
7
+ end
8
+
9
+ end
@@ -0,0 +1,22 @@
1
+ module Churn
2
+
3
+ class Svn < SourceControl
4
+ def get_logs
5
+ `svn log #{date_range} --verbose`.split(/\n/).map { |line| clean_up_svn_line(line) }.compact
6
+ end
7
+
8
+ private
9
+ def date_range
10
+ if @start_date
11
+ date = Chronic.parse(@start_date)
12
+ "--revision {#{date.strftime('%Y-%m-%d')}}:{#{Time.now.strftime('%Y-%m-%d')}}"
13
+ end
14
+ end
15
+
16
+ def clean_up_svn_line(line)
17
+ m = line.match(/\W*[A,M]\W+(\/.*)\b/)
18
+ m ? m[1] : nil
19
+ end
20
+ end
21
+
22
+ end
@@ -0,0 +1,20 @@
1
+ def report_churn()
2
+ require File.join(File.dirname(__FILE__), '..', 'churn', 'churn_calculator')
3
+ Churn::ChurnCalculator.new({:minimum_churn_count => 3}).report
4
+ end
5
+
6
+ desc "Report the current churn for the project"
7
+ task :churn do
8
+ report = report_churn()
9
+ puts report
10
+ # puts "entire report"
11
+ # puts report.inspect.to_s
12
+ # puts "_"*50
13
+ # puts "changed classes: #{report[:churn][:changed_classes].inspect}"
14
+ # puts "_"*50
15
+ # puts "cahnged methods: #{report[:churn][:changed_methods].inspect}"
16
+ # puts "_"*50
17
+ # put
18
+ # s "method churn: #{report[:churn][:method_churn].inspect}"
19
+ end
20
+
@@ -0,0 +1,217 @@
1
+ require 'chronic'
2
+ require 'sexp_processor'
3
+ require 'ruby_parser'
4
+ require 'json'
5
+ require 'fileutils'
6
+ require 'lib/churn/source_control'
7
+ require 'lib/churn/git_analyzer'
8
+ require 'lib/churn/svn_analyzer'
9
+ require 'lib/churn/location_mapping'
10
+ require 'lib/churn/churn_history'
11
+
12
+ module Churn
13
+
14
+ class ChurnCalculator
15
+
16
+ def initialize(options={})
17
+ start_date = options.fetch(:start_date) { '3 months ago' }
18
+ @minimum_churn_count = options.fetch(:minimum_churn_count) { 5 }
19
+ puts start_date
20
+ if self.class.git?
21
+ @source_control = GitAnalyzer.new(start_date)
22
+ elsif File.exist?(".svn")
23
+ @source_control = SvnAnalyzer.new(start_date)
24
+ else
25
+ raise "Churning requires a subversion or git repo"
26
+ end
27
+ @revision_changes = {}
28
+ @method_changes = {}
29
+ @class_changes = {}
30
+ end
31
+
32
+ def report
33
+ self.emit
34
+ self.analyze
35
+ self.to_h
36
+ end
37
+
38
+ def emit
39
+ @changes = parse_log_for_changes.reject {|file, change_count| change_count < @minimum_churn_count}
40
+ @revisions = parse_log_for_revision_changes
41
+ end
42
+
43
+ def analyze
44
+ @changes = @changes.to_a.sort {|x,y| y[1] <=> x[1]}
45
+ @changes = @changes.map {|file_path, times_changed| {:file_path => file_path, :times_changed => times_changed }}
46
+
47
+ calculate_revision_changes
48
+
49
+ @method_changes.to_a.sort {|x,y| y[1] <=> x[1]}
50
+ @method_changes = @method_changes.map {|method, times_changed| {'method' => method, 'times_changed' => times_changed }}
51
+ @class_changes.to_a.sort {|x,y| y[1] <=> x[1]}
52
+ @class_changes = @class_changes.map {|klass, times_changed| {'klass' => klass, 'times_changed' => times_changed }}
53
+ end
54
+
55
+ def to_h
56
+ hash = {:churn => {:changes => @changes}}
57
+ hash[:churn][:method_churn] = @method_changes
58
+ hash[:churn][:class_churn] = @class_changes
59
+ #detail the most recent changes made this revision
60
+ if @revision_changes[@revisions.first]
61
+ changes = @revision_changes[@revisions.first]
62
+ hash[:churn][:changed_files] = changes[:files]
63
+ hash[:churn][:changed_classes] = changes[:classes]
64
+ hash[:churn][:changed_methods] = changes[:methods]
65
+ end
66
+ #TODO crappy place to do this but save hash to revision file but while entirely under metric_fu only choice
67
+ revision = @revisions.first
68
+ ChurnHistory.store_revision_history(revision, hash)
69
+ hash
70
+ end
71
+
72
+ private
73
+
74
+ def self.git?
75
+ system("git branch")
76
+ end
77
+
78
+ def calculate_revision_changes
79
+ @revisions.each do |revision|
80
+ if revision == @revisions.first
81
+ #can't iterate through all the changes and tally them up
82
+ #it only has the current files not the files at the time of the revision
83
+ #parsing requires the files
84
+ changed_files, changed_classes, changed_methods = calculate_revision_data(revision)
85
+ else
86
+ changed_files, changed_classes, changed_methods = ChurnHistory.load_revision_data(revision)
87
+ end
88
+ calculate_changes!(changed_methods, @method_changes) if changed_methods
89
+ calculate_changes!(changed_classes, @class_changes) if changed_classes
90
+
91
+ @revision_changes[revision] = { :files => changed_files, :classes => changed_classes, :methods => changed_methods }
92
+ end
93
+ end
94
+
95
+ def calculate_revision_data(revision)
96
+ changed_files = parse_logs_for_updated_files(revision, @revisions)
97
+
98
+ changed_classes = []
99
+ changed_methods = []
100
+ changed_files.each do |file|
101
+ classes, methods = get_changes(file)
102
+ changed_classes += classes
103
+ changed_methods += methods
104
+ end
105
+ changed_files = changed_files.map { |file, lines| file }
106
+ [changed_files, changed_classes, changed_methods]
107
+ end
108
+
109
+ def calculate_changes!(changed, total_changes)
110
+ if changed
111
+ changed.each do |change|
112
+ total_changes.include?(change) ? total_changes[change] = total_changes[change]+1 : total_changes[change] = 1
113
+ end
114
+ end
115
+ total_changes
116
+ end
117
+
118
+ def get_changes(change)
119
+ begin
120
+ file = change.first
121
+ breakdown = LocationMapping.new
122
+ breakdown.get_info(file)
123
+ changes = change.last
124
+ classes = changes_for_type(changes, breakdown, :classes)
125
+ methods = changes_for_type(changes, breakdown, :methods)
126
+ #todo move to method
127
+ classes = classes.map{ |klass| {'file' => file, 'klass' => klass} }
128
+ methods = methods.map{ |method| {'file' => file, 'klass' => get_klass_for(method), 'method' => method} }
129
+ [classes, methods]
130
+ rescue => error
131
+ [[],[]]
132
+ end
133
+ end
134
+
135
+ def get_klass_for(method)
136
+ method.gsub(/(#|\.).*/,'')
137
+ end
138
+
139
+ def changes_for_type(changes, breakdown, type)
140
+ item_collection = if type == :classes
141
+ breakdown.klasses_collection
142
+ elsif type == :methods
143
+ breakdown.methods_collection
144
+ end
145
+ changed_items = []
146
+ item_collection.each_pair do |item, item_lines|
147
+ item_lines = item_lines[0].to_a
148
+ changes.each do |change_range|
149
+ item_lines.each do |line|
150
+ changed_items << item if change_range.include?(line) && !changed_items.include?(item)
151
+ end
152
+ end
153
+ end
154
+ changed_items
155
+ end
156
+
157
+ def parse_log_for_changes
158
+ changes = {}
159
+
160
+ logs = @source_control.get_logs
161
+ logs.each do |line|
162
+ changes[line] ? changes[line] += 1 : changes[line] = 1
163
+ end
164
+ changes
165
+ end
166
+
167
+ def parse_log_for_revision_changes
168
+ @source_control.get_revisions
169
+ end
170
+
171
+ def parse_logs_for_updated_files(revision, revisions)
172
+ updated = {}
173
+ recent_file = nil
174
+
175
+ #SVN doesn't support this
176
+ return updated unless @source_control.respond_to?(:get_updated_files_from_log)
177
+ logs = @source_control.get_updated_files_from_log(revision, revisions)
178
+ logs.each do |line|
179
+ if line.match(/^---/) || line.match(/^\+\+\+/)
180
+ line = line.gsub(/^--- /,'').gsub(/^\+\+\+ /,'').gsub(/^a\//,'').gsub(/^b\//,'')
181
+ unless updated.include?(line)
182
+ updated[line] = []
183
+ end
184
+ recent_file = line
185
+ elsif line.match(/^@@/)
186
+ #TODO cleanup / refactor
187
+ #puts "#{recent_file}: #{line}"
188
+ removed = line.match(/-[0-9]+/)
189
+ removed_length = line.match(/-[0-9]+,[0-9]+/)
190
+ removed = removed.to_s.gsub(/-/,'')
191
+ removed_length = removed_length.to_s.gsub(/.*,/,'')
192
+ added = line.match(/\+[0-9]+/)
193
+ added_length = line.match(/\+[0-9]+,[0-9]+/)
194
+ added = added.to_s.gsub(/\+/,'')
195
+ added_length = added_length.to_s.gsub(/.*,/,'')
196
+ removed_range = if removed_length && removed_length!=''
197
+ (removed.to_i..(removed.to_i+removed_length.to_i))
198
+ else
199
+ (removed.to_i..removed.to_i)
200
+ end
201
+ added_range = if added_length && added_length!=''
202
+ (added.to_i..(added.to_i+added_length.to_i))
203
+ else
204
+ (added.to_i..added.to_i)
205
+ end
206
+ updated[recent_file] << removed_range
207
+ updated[recent_file] << added_range
208
+ else
209
+ raise "git diff lines that don't match the two patterns aren't expected"
210
+ end
211
+ end
212
+ updated
213
+ end
214
+
215
+ end
216
+
217
+ end
@@ -0,0 +1,14 @@
1
+ require 'rubygems'
2
+ require 'test/unit'
3
+ require 'shoulda'
4
+ require 'construct'
5
+ require 'mocha'
6
+
7
+ $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
8
+ $LOAD_PATH.unshift(File.dirname(__FILE__))
9
+ require 'churn/churn_calculator'
10
+ Mocha::Configuration.prevent(:stubbing_non_existent_method)
11
+
12
+ class Test::Unit::TestCase
13
+ include Construct::Helpers
14
+ end
@@ -0,0 +1,14 @@
1
+ require 'rubygems'
2
+ require 'test/unit'
3
+ require 'shoulda'
4
+ require 'construct'
5
+ require 'mocha'
6
+
7
+ $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
8
+ $LOAD_PATH.unshift(File.dirname(__FILE__))
9
+ require 'churn/churn_calculator'
10
+ Mocha::Configuration.prevent(:stubbing_non_existent_method)
11
+
12
+ class Test::Unit::TestCase
13
+ include Construct::Helpers
14
+ end
@@ -0,0 +1,82 @@
1
+ require File.expand_path('../test_helper', File.dirname(__FILE__))
2
+
3
+ class ChurnCalculatorTest < Test::Unit::TestCase
4
+
5
+ should "use minimum churn count" do
6
+ within_construct do |container|
7
+ Churn::ChurnCalculator.stubs(:git?).returns(true)
8
+ churn = Churn::ChurnCalculator.new({:minimum_churn_count => 3})
9
+
10
+ churn.stubs(:parse_log_for_changes).returns([['file.rb', 4],['less.rb',1]])
11
+ churn.stubs(:parse_log_for_revision_changes).returns(['revision'])
12
+ churn.stubs(:analyze)
13
+ report = churn.report(false)
14
+ assert_equal 1, report[:churn][:changes].length
15
+ assert_equal ["file.rb", 4], report[:churn][:changes].first
16
+ end
17
+ end
18
+
19
+ should "analize sorts changes" do
20
+ within_construct do |container|
21
+ Churn::ChurnCalculator.stubs(:git?).returns(true)
22
+ churn = Churn::ChurnCalculator.new({:minimum_churn_count => 3})
23
+
24
+ churn.stubs(:parse_log_for_changes).returns([['file.rb', 4],['most.rb', 9],['less.rb',1]])
25
+ churn.stubs(:parse_log_for_revision_changes).returns(['revision'])
26
+ report = churn.report(false)
27
+ assert_equal 2, report[:churn][:changes].length
28
+ top = {:file_path => "most.rb", :times_changed => 9}
29
+ assert_equal top, report[:churn][:changes].first
30
+ bottom = {:file_path => "file.rb", :times_changed => 4}
31
+ assert_equal bottom, report[:churn][:changes].last
32
+ end
33
+ end
34
+
35
+ should "have correct changed_files data" do
36
+ within_construct do |container|
37
+ Churn::ChurnCalculator.stubs(:git?).returns(true)
38
+ churn = Churn::ChurnCalculator.new({:minimum_churn_count => 3})
39
+
40
+ churn.stubs(:parse_log_for_changes).returns([['less.rb',1]])
41
+ churn.stubs(:parse_log_for_revision_changes).returns(['first'])
42
+ churn.stubs(:parse_logs_for_updated_files).returns(['fake_file.rb'])
43
+ report = churn.report(false)
44
+ assert_equal ["fake_file.rb"], report[:churn][:changed_files]
45
+ end
46
+ end
47
+
48
+ should "have correct changed classes and methods data" do
49
+ within_construct do |container|
50
+ Churn::ChurnCalculator.stubs(:git?).returns(true)
51
+ churn = Churn::ChurnCalculator.new({:minimum_churn_count => 3})
52
+
53
+ churn.stubs(:parse_log_for_changes).returns([['less.rb',1]])
54
+ churn.stubs(:parse_log_for_revision_changes).returns(['first'])
55
+ churn.stubs(:parse_logs_for_updated_files).returns(['fake_file.rb'])
56
+ klasses = [{"klass"=>"LocationMapping", "file"=>"lib/churn/location_mapping.rb"}]
57
+ methods = [{"klass"=>"LocationMapping", "method"=>"LocationMapping#process_class", "file"=>"lib/churn/location_mapping.rb"}]
58
+ churn.stubs(:get_changes).returns([klasses,methods])
59
+ report = churn.report(false)
60
+ assert_equal [{"klass"=>"LocationMapping", "method"=>"LocationMapping#process_class", "file"=>"lib/churn/location_mapping.rb"}], report[:churn][:changed_methods]
61
+ assert_equal [{"klass"=>"LocationMapping", "file"=>"lib/churn/location_mapping.rb"}], report[:churn][:changed_classes]
62
+ end
63
+ end
64
+
65
+ should "have correct churn method and classes at 1 change" do
66
+ within_construct do |container|
67
+ Churn::ChurnCalculator.stubs(:git?).returns(true)
68
+ churn = Churn::ChurnCalculator.new({:minimum_churn_count => 3})
69
+
70
+ churn.stubs(:parse_log_for_changes).returns([['less.rb',1]])
71
+ churn.stubs(:parse_log_for_revision_changes).returns(['first'])
72
+ churn.stubs(:parse_logs_for_updated_files).returns(['fake_file.rb'])
73
+ klasses = [{"klass"=>"LocationMapping", "file"=>"lib/churn/location_mapping.rb"}]
74
+ methods = [{"klass"=>"LocationMapping", "method"=>"LocationMapping#process_class", "file"=>"lib/churn/location_mapping.rb"}]
75
+ churn.stubs(:get_changes).returns([klasses,methods])
76
+ report = churn.report(false)
77
+ assert_equal [{"method"=>{"klass"=>"LocationMapping", "method"=>"LocationMapping#process_class", "file"=>"lib/churn/location_mapping.rb"}, "times_changed"=>1}], report[:churn][:method_churn]
78
+ assert_equal [{"klass"=>{"klass"=>"LocationMapping", "file"=>"lib/churn/location_mapping.rb"}, "times_changed"=>1}], report[:churn][:class_churn]
79
+ end
80
+ end
81
+
82
+ end
@@ -0,0 +1,24 @@
1
+ require File.expand_path('../test_helper', File.dirname(__FILE__))
2
+
3
+ class ChurnHistoryTest < Test::Unit::TestCase
4
+
5
+ should "store results" do
6
+ within_construct do |container|
7
+ Churn::ChurnHistory.store_revision_history('aaa','data')
8
+ assert File.exists?('tmp/aaa.json')
9
+ data = File.read('tmp/aaa.json')
10
+ assert data.match(/data/)
11
+ end
12
+ end
13
+
14
+ should "restores results" do
15
+ within_construct do |container|
16
+ container.file('tmp/aaa.json', '{"churn":{"changes":[{"file_path":".gitignore","times_changed":2},{"file_path":"lib\/churn.rb","times_changed":2},{"file_path":"Rakefile","times_changed":2},{"file_path":"README.rdoc","times_changed":2},{"file_path":"lib\/churn\/source_control.rb","times_changed":1},{"file_path":"lib\/churn\/svn_analyzer.rb","times_changed":1},{"file_path":"lib\/tasks\/churn_tasks.rb","times_changed":1},{"file_path":"LICENSE","times_changed":1},{"file_path":"test\/churn_test.rb","times_changed":1},{"file_path":"lib\/churn\/locationmapping.rb","times_changed":1},{"file_path":"lib\/churn\/git_analyzer.rb","times_changed":1},{"file_path":".document","times_changed":1},{"file_path":"test\/test_helper.rb","times_changed":1},{"file_path":"lib\/churn\/churn_calculator.rb","times_changed":1}],"method_churn":[],"changed_files":[".gitignore","lib\/churn\/source_control.rb","lib\/tasks\/churn_tasks.rb","lib\/churn\/svn_analyzer.rb","Rakefile","README.rdoc","lib\/churn\/locationmapping.rb","lib\/churn\/git_analyzer.rb","\/dev\/null","lib\/churn\/churn_calculator.rb","lib\/churn.rb"],"class_churn":[],"changed_classes":[{"klass":"ChurnTest","file":"test\/churn_test.rb"},{"klass":"ChurnCalculator","file":"lib\/churn\/churn_calculator.rb"}],"changed_methods":[{"klass":"","method":"#report_churn","file":"lib\/tasks\/churn_tasks.rb"}]}}')
17
+ changed_files, changed_classes, changed_methods = Churn::ChurnHistory.load_revision_data('aaa')
18
+ assert changed_files.include?("lib/churn/source_control.rb")
19
+ assert_equal 2, changed_classes.length
20
+ assert_equal 1, changed_methods.length
21
+ end
22
+ end
23
+
24
+ end
@@ -0,0 +1,17 @@
1
+ require File.expand_path('../test_helper', File.dirname(__FILE__))
2
+
3
+ class GitAnalyzerTest < Test::Unit::TestCase
4
+
5
+ should "parses logs correctly" do
6
+ git_analyzer = Churn::GitAnalyzer.new
7
+ revision = 'first'
8
+ revisions = ['first']
9
+ lines = ["--- a/lib/churn/churn_calculator.rb", "+++ b/lib/churn/churn_calculator.rb", "@@ -18,0 +19 @@ module Churn"]
10
+ git_analyzer.stubs(:get_updated_files_from_log).returns(lines)
11
+ updated = git_analyzer.get_updated_files_change_info(revision, revisions)
12
+ expected_hash = {"lib/churn/churn_calculator.rb"=>[18..18, 19..19]}
13
+ assert_equal = updated
14
+ end
15
+
16
+ end
17
+
@@ -0,0 +1,35 @@
1
+ require File.expand_path('../test_helper', File.dirname(__FILE__))
2
+
3
+ class LocationMappingTest < Test::Unit::TestCase
4
+
5
+ #todo unfortunately it looks like ruby parser can't handle construct tmp dirs
6
+ #<Pathname:/private/var/folders/gl/glhHkYYSGgG5nb6+4OG0yU+++TI/-Tmp-/construct_container-56784-851001101/fake_class.rb>
7
+ #(rdb:1) p locationmapping.get_info(file.to_s)
8
+ #RegexpError Exception: invalid regular expression; there's no previous pattern, to which '+' would define cardinality at 2: /^+++/
9
+
10
+ should "location_mapping gets correct classes info" do
11
+ file = 'test/data/churn_calculator.rb'
12
+ locationmapping = Churn::LocationMapping.new
13
+ locationmapping.get_info(file.to_s)
14
+ klass_hash = {"ChurnCalculator"=>[14..215]}
15
+ assert_equal klass_hash, locationmapping.klasses_collection
16
+ end
17
+
18
+ should "location_mapping gets correct methods info" do
19
+ file = 'test/data/churn_calculator.rb'
20
+ locationmapping = Churn::LocationMapping.new
21
+ locationmapping.get_info(file.to_s)
22
+ methods_hash = {"ChurnCalculator#report"=>[32..36], "ChurnCalculator#emit"=>[38..41], "ChurnCalculator#changes_for_type"=>[139..155], "ChurnCalculator#get_klass_for"=>[135..137], "ChurnCalculator#calculate_changes!"=>[109..116], "ChurnCalculator#analyze"=>[43..53], "ChurnCalculator#calculate_revision_data"=>[95..107], "ChurnCalculator#calculate_revision_changes"=>[78..93], "ChurnCalculator#parse_logs_for_updated_files"=>[171..213], "ChurnCalculator#to_h"=>[55..70], "ChurnCalculator#parse_log_for_revision_changes"=>[167..169], "ChurnCalculator#get_changes"=>[118..133], "ChurnCalculator#parse_log_for_changes"=>[157..165], "ChurnCalculator#initialize"=>[16..30]}
23
+ assert_equal methods_hash, locationmapping.methods_collection
24
+ end
25
+
26
+ should "location_mapping gets correct classes info for test helper files" do
27
+ file = 'test/data/test_helper.rb'
28
+ locationmapping = Churn::LocationMapping.new
29
+ locationmapping.get_info(file.to_s)
30
+ klass_hash = {"TestCase"=>[12..14]}
31
+ assert_equal klass_hash, locationmapping.klasses_collection
32
+ end
33
+
34
+ end
35
+
metadata ADDED
@@ -0,0 +1,91 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: churn
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.2
5
+ platform: ruby
6
+ authors:
7
+ - Dan Mayer
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+
12
+ date: 2010-01-08 00:00:00 -05:00
13
+ default_executable:
14
+ dependencies:
15
+ - !ruby/object:Gem::Dependency
16
+ name: thoughtbot-shoulda
17
+ type: :development
18
+ version_requirement:
19
+ version_requirements: !ruby/object:Gem::Requirement
20
+ requirements:
21
+ - - ">="
22
+ - !ruby/object:Gem::Version
23
+ version: "0"
24
+ version:
25
+ description: "High method and class churn has been shown to have increased bug and error rates. This gem helps you know what is changing a lot so you can do additional testing, code review, or refactoring to try to tame the volatile code. "
26
+ email: Danmayer@gmail.com
27
+ executables: []
28
+
29
+ extensions: []
30
+
31
+ extra_rdoc_files:
32
+ - LICENSE
33
+ - README.rdoc
34
+ files:
35
+ - .document
36
+ - .gitignore
37
+ - LICENSE
38
+ - README.rdoc
39
+ - Rakefile
40
+ - VERSION
41
+ - lib/churn.rb
42
+ - lib/churn/churn_calculator.rb
43
+ - lib/churn/churn_history.rb
44
+ - lib/churn/git_analyzer.rb
45
+ - lib/churn/location_mapping.rb
46
+ - lib/churn/source_control.rb
47
+ - lib/churn/svn_analyzer.rb
48
+ - lib/tasks/churn_tasks.rb
49
+ - test/data/churn_calculator.rb
50
+ - test/data/test_helper.rb
51
+ - test/test_helper.rb
52
+ - test/unit/churn_calculator_test.rb
53
+ - test/unit/churn_history_test.rb
54
+ - test/unit/git_analyzer_test.rb
55
+ - test/unit/location_mapping_test.rb
56
+ has_rdoc: true
57
+ homepage: http://github.com/danmayer/churn
58
+ licenses: []
59
+
60
+ post_install_message:
61
+ rdoc_options:
62
+ - --charset=UTF-8
63
+ require_paths:
64
+ - lib
65
+ required_ruby_version: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - ">="
68
+ - !ruby/object:Gem::Version
69
+ version: "0"
70
+ version:
71
+ required_rubygems_version: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: "0"
76
+ version:
77
+ requirements: []
78
+
79
+ rubyforge_project:
80
+ rubygems_version: 1.3.5
81
+ signing_key:
82
+ specification_version: 3
83
+ summary: Providing additional churn metrics over the original metric_fu churn
84
+ test_files:
85
+ - test/data/churn_calculator.rb
86
+ - test/data/test_helper.rb
87
+ - test/test_helper.rb
88
+ - test/unit/churn_calculator_test.rb
89
+ - test/unit/churn_history_test.rb
90
+ - test/unit/git_analyzer_test.rb
91
+ - test/unit/location_mapping_test.rb