metric_fu 1.5.1 → 2.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (40) hide show
  1. data/HISTORY +5 -0
  2. data/MIT-LICENSE +1 -1
  3. data/README +8 -6
  4. data/Rakefile +5 -3
  5. data/TODO +0 -5
  6. data/lib/base/base_template.rb +16 -0
  7. data/lib/base/churn_analyzer.rb +52 -0
  8. data/lib/base/code_issue.rb +97 -0
  9. data/lib/base/configuration.rb +4 -2
  10. data/lib/base/flay_analyzer.rb +50 -0
  11. data/lib/base/flog_analyzer.rb +43 -0
  12. data/lib/base/line_numbers.rb +65 -0
  13. data/lib/base/location.rb +83 -0
  14. data/lib/base/metric_analyzer.rb +404 -0
  15. data/lib/base/ranking.rb +33 -0
  16. data/lib/base/rcov_analyzer.rb +43 -0
  17. data/lib/base/reek_analyzer.rb +114 -0
  18. data/lib/base/roodi_analyzer.rb +37 -0
  19. data/lib/base/saikuro_analyzer.rb +48 -0
  20. data/lib/base/scoring_strategies.rb +29 -0
  21. data/lib/base/stats_analyzer.rb +37 -0
  22. data/lib/base/table.rb +102 -0
  23. data/lib/generators/hotspots.rb +52 -0
  24. data/lib/generators/rcov.rb +41 -0
  25. data/lib/metric_fu.rb +5 -3
  26. data/lib/templates/awesome/hotspots.html.erb +54 -0
  27. data/lib/templates/awesome/index.html.erb +3 -0
  28. data/lib/templates/standard/hotspots.html.erb +54 -0
  29. data/spec/base/line_numbers_spec.rb +62 -0
  30. data/spec/generators/rails_best_practices_spec.rb +52 -0
  31. data/spec/generators/rcov_spec.rb +180 -0
  32. data/spec/generators/roodi_spec.rb +24 -0
  33. data/spec/graphs/rails_best_practices_grapher_spec.rb +61 -0
  34. data/spec/graphs/stats_grapher_spec.rb +68 -0
  35. data/spec/resources/line_numbers/foo.rb +33 -0
  36. data/spec/resources/line_numbers/module.rb +11 -0
  37. data/spec/resources/line_numbers/module_surrounds_class.rb +15 -0
  38. data/spec/resources/line_numbers/two_classes.rb +11 -0
  39. data/spec/resources/yml/metric_missing.yml +1 -0
  40. metadata +51 -11
@@ -0,0 +1,404 @@
1
+ class AnalysisError < RuntimeError; end;
2
+
3
+ class MetricAnalyzer
4
+
5
+ COMMON_COLUMNS = %w{metric}
6
+ GRANULARITIES = %w{file_path class_name method_name}
7
+
8
+ attr_accessor :table
9
+
10
+ def initialize(yaml)
11
+ if(yaml.is_a?(String))
12
+ @yaml = YAML.load(yaml)
13
+ else
14
+ @yaml = yaml
15
+ end
16
+ @file_ranking = Ranking.new
17
+ @class_ranking = Ranking.new
18
+ @method_ranking = Ranking.new
19
+ rankings = [@file_ranking, @class_ranking, @method_ranking]
20
+
21
+ tool_analyzers = [ReekAnalyzer.new, RoodiAnalyzer.new,
22
+ FlogAnalyzer.new, ChurnAnalyzer.new, SaikuroAnalyzer.new,
23
+ FlayAnalyzer.new, StatsAnalyzer.new, RcovAnalyzer.new]
24
+ # TODO There is likely a clash that will happen between
25
+ # column names eventually. We should probably auto-prefix
26
+ # them (e.g. "roodi_problem")
27
+ columns = COMMON_COLUMNS + GRANULARITIES + tool_analyzers.map{|analyzer| analyzer.columns}.flatten
28
+
29
+ @table = make_table(columns)
30
+
31
+ # These tables are an optimization. They contain subsets of the master table.
32
+ # TODO - these should be pushed into the Table class now
33
+ @tool_tables = make_table_hash(columns)
34
+ @file_tables = make_table_hash(columns)
35
+ @class_tables = make_table_hash(columns)
36
+ @method_tables = make_table_hash(columns)
37
+
38
+ tool_analyzers.each do |analyzer|
39
+ analyzer.generate_records(@yaml[analyzer.name], @table)
40
+ end
41
+
42
+ build_lookups!(table)
43
+ process_rows!(table)
44
+
45
+ tool_analyzers.each do |analyzer|
46
+ GRANULARITIES.each do |granularity|
47
+ metric_ranking = calculate_metric_scores(granularity, analyzer)
48
+ add_to_master_ranking(ranking(granularity), metric_ranking, analyzer)
49
+ end
50
+ end
51
+
52
+ rankings.each do |ranking|
53
+ ranking.delete(nil)
54
+ end
55
+ end
56
+
57
+ def location(item, value)
58
+ sub_table = get_sub_table(item, value)
59
+ if(sub_table.length==0)
60
+ raise AnalysisError, "The #{item.to_s} '#{value.to_s}' does not have any rows in the analysis table"
61
+ else
62
+ first_row = sub_table[0]
63
+ case item
64
+ when :class
65
+ Location.get(first_row.file_path, first_row.class_name, nil)
66
+ when :method
67
+ Location.get(first_row.file_path, first_row.class_name, first_row.method_name)
68
+ when :file
69
+ Location.get(first_row.file_path, nil, nil)
70
+ else
71
+ raise ArgumentError, "Item must be :class, :method, or :file"
72
+ end
73
+ end
74
+ end
75
+
76
+ #todo redo as item,value, options = {}
77
+ # Note that the other option for 'details' is :detailed (this isn't
78
+ # at all clear from this method itself
79
+ def problems_with(item, value, details = :summary, exclude_details = [])
80
+ sub_table = get_sub_table(item, value)
81
+ #grouping = Ruport::Data::Grouping.new(sub_table, :by => 'metric')
82
+ grouping = get_grouping(sub_table, :by => 'metric')
83
+ problems = {}
84
+ grouping.each do |metric, table|
85
+ if details == :summary || exclude_details.include?(metric)
86
+ problems[metric] = present_group(metric,table)
87
+ else
88
+ problems[metric] = present_group_details(metric,table)
89
+ end
90
+ end
91
+ problems
92
+ end
93
+
94
+ def worst_methods(size = nil)
95
+ @method_ranking.top(size)
96
+ end
97
+
98
+ def worst_classes(size = nil)
99
+ @class_ranking.top(size)
100
+ end
101
+
102
+ def worst_files(size = nil)
103
+ @file_ranking.top(size)
104
+ end
105
+
106
+ private
107
+
108
+ def get_grouping(table, opts)
109
+ #Ruport::Data::Grouping.new(table, opts)
110
+ Grouping.new(table, opts)
111
+ #@grouping_cache ||= {}
112
+ #@grouping_cache.fetch(grouping_key(table,opts)) do
113
+ # @grouping_cache[grouping_key(table,opts)] = Ruport::Data::Grouping.new(table, opts)
114
+ #end
115
+ end
116
+
117
+ def grouping_key(table, opts)
118
+ "table #{table.object_id} opts #{opts.inspect}"
119
+ end
120
+
121
+ def build_lookups!(table)
122
+ @class_and_method_to_file ||= {}
123
+ # Build a mapping from [class,method] => filename
124
+ # (and make sure the mapping is unique)
125
+ table.each do |row|
126
+ # We know that Saikuro provides the wrong data
127
+ next if row['metric'] == :saikuro
128
+ key = [row['class_name'], row['method_name']]
129
+ file_path = row['file_path']
130
+ @class_and_method_to_file[key] ||= file_path
131
+ end
132
+ end
133
+
134
+ def process_rows!(table)
135
+ # Correct incorrect rows in the table
136
+ table.each do |row|
137
+ row_metric = row['metric'] #perf optimization
138
+ if row_metric == :saikuro
139
+ fix_row_file_path!(row)
140
+ end
141
+ @tool_tables[row_metric] << row
142
+ @file_tables[row["file_path"]] << row
143
+ @class_tables[row["class_name"]] << row
144
+ @method_tables[row["method_name"]] << row
145
+ end
146
+ end
147
+
148
+ def fix_row_file_path!(row)
149
+ # We know that Saikuro rows are broken
150
+ next unless row['metric'] == :saikuro
151
+ key = [row['class_name'], row['method_name']]
152
+ current_file_path = row['file_path'].to_s
153
+ correct_file_path = @class_and_method_to_file[key]
154
+ if(correct_file_path!=nil && correct_file_path.include?(current_file_path))
155
+ row['file_path'] = correct_file_path
156
+ else
157
+ # There wasn't an exact match, so we can do a substring match
158
+ matching_file_path = file_paths.detect {|file_path|
159
+ file_path!=nil && file_path.include?(current_file_path)
160
+ }
161
+ if(matching_file_path)
162
+ row['file_path'] = matching_file_path
163
+ end
164
+ end
165
+ end
166
+
167
+ def file_paths
168
+ @file_paths ||= @table.column('file_path').uniq
169
+ end
170
+
171
+ def ranking(column_name)
172
+ case column_name
173
+ when "file_path"
174
+ @file_ranking
175
+ when "class_name"
176
+ @class_ranking
177
+ when "method_name"
178
+ @method_ranking
179
+ else
180
+ raise ArgumentError, "Invalid column name #{column_name}"
181
+ end
182
+ end
183
+
184
+ def calculate_metric_scores(granularity, analyzer)
185
+ metric_ranking = Ranking.new
186
+ metric_violations = @tool_tables[analyzer.name]
187
+ metric_violations.each do |row|
188
+ location = row[granularity]
189
+ metric_ranking[location] ||= []
190
+ metric_ranking[location] << analyzer.map(row)
191
+ end
192
+
193
+ metric_ranking.each do |item, scores|
194
+ metric_ranking[item] = analyzer.reduce(scores)
195
+ end
196
+
197
+ metric_ranking
198
+ end
199
+
200
+ def add_to_master_ranking(master_ranking, metric_ranking, analyzer)
201
+ metric_ranking.each do |item, _|
202
+ master_ranking[item] ||= 0
203
+ master_ranking[item] += analyzer.score(metric_ranking, item)
204
+ end
205
+ end
206
+
207
+ def most_common_column(column_name, size)
208
+ #grouping = Ruport::Data::Grouping.new(@table,
209
+ # :by => column_name,
210
+ # :order => lambda { |g| -g.size})
211
+ get_grouping(@table, :by => column_name, :order => lambda {|g| -g.size})
212
+ values = []
213
+ grouping.each do |value, _|
214
+ values << value if value!=nil
215
+ if(values.size==size)
216
+ break
217
+ end
218
+ end
219
+ return nil if values.empty?
220
+ if(values.size == 1)
221
+ return values.first
222
+ else
223
+ return values
224
+ end
225
+ end
226
+
227
+ # TODO: As we get fancier, the presenter should
228
+ # be its own class, not just a method with a long
229
+ # case statement
230
+ def present_group(metric, group)
231
+ occurences = group.size
232
+ case(metric)
233
+ when :reek
234
+ "found #{occurences} code smells"
235
+ when :roodi
236
+ "found #{occurences} design problems"
237
+ when :churn
238
+ "detected high level of churn (changed #{group[0].times_changed} times)"
239
+ when :flog
240
+ complexity = get_mean(group.column("score"))
241
+ "#{"average " if occurences > 1}complexity is %.1f" % complexity
242
+ when :saikuro
243
+ complexity = get_mean(group.column("complexity"))
244
+ "#{"average " if occurences > 1}complexity is %.1f" % complexity
245
+ when :flay
246
+ "found #{occurences} code duplications"
247
+ when :rcov
248
+ average_code_uncoverage = get_mean(group.column("percentage_uncovered"))
249
+ "#{"average " if occurences > 1}uncovered code is %.1f%" % average_code_uncoverage
250
+ else
251
+ raise AnalysisError, "Unknown metric #{metric}"
252
+ end
253
+ end
254
+
255
+ def present_group_details(metric, group)
256
+ occurences = group.size
257
+ case(metric)
258
+ when :reek
259
+ message = "found #{occurences} code smells<br/>"
260
+ group.each do |item|
261
+ type = item.data["reek__type_name"]
262
+ reek_message = item.data["reek__message"]
263
+ message << "* #{type}: #{reek_message}<br/>"
264
+ end
265
+ message
266
+ when :roodi
267
+ message = "found #{occurences} design problems<br/>"
268
+ group.each do |item|
269
+ problem = item.data["problems"]
270
+ message << "* #{problem}<br/>"
271
+ end
272
+ message
273
+ when :churn
274
+ "detected high level of churn (changed #{group[0].times_changed} times)"
275
+ when :flog
276
+ complexity = get_mean(group.column("score"))
277
+ "#{"average " if occurences > 1}complexity is %.1f" % complexity
278
+ when :saikuro
279
+ complexity = get_mean(group.column("complexity"))
280
+ "#{"average " if occurences > 1}complexity is %.1f" % complexity
281
+ when :flay
282
+ message = "found #{occurences} code duplications<br/>"
283
+ group.each do |item|
284
+ problem = item.data["flay_reason"]
285
+ problem = problem.gsub(/^[0-9]*\)/,'')
286
+ problem = problem.gsub(/files\:/,' <br>&nbsp;&nbsp;&nbsp;files:')
287
+ message << "* #{problem}<br/>"
288
+ end
289
+ message
290
+ else
291
+ raise AnalysisError, "Unknown metric #{metric}"
292
+ end
293
+ end
294
+
295
+ def make_table_hash(columns)
296
+ Hash.new { |hash, key|
297
+ hash[key] = make_table(columns)
298
+ }
299
+ end
300
+
301
+ def make_table(columns)
302
+ Table.new(:column_names => columns)
303
+ end
304
+
305
+ def get_sub_table(item, value)
306
+ tables = {
307
+ :class => @class_tables,
308
+ :method => @method_tables,
309
+ :file => @file_tables,
310
+ :tool => @tool_tables
311
+ }.fetch(item) do
312
+ raise ArgumentError, "Item must be :class, :method, or :file"
313
+ end
314
+ tables[value]
315
+ end
316
+
317
+ def get_mean(collection)
318
+ collection_length = collection.length
319
+ sum = 0
320
+ sum = collection.inject( nil ) { |sum,x| sum ? sum+x : x }
321
+ (sum.to_f / collection_length.to_f)
322
+ end
323
+
324
+ end
325
+
326
+ class Record
327
+
328
+ attr_reader :data
329
+
330
+ def initialize(data, columns)
331
+ @data = data
332
+ @columns = columns
333
+ end
334
+
335
+ def method_missing(name, *args, &block)
336
+ key = name.to_s
337
+ if @data.has_key?(key)
338
+ @data[key]
339
+ elsif @columns.member?(key)
340
+ nil
341
+ else
342
+ super(name, *args, &block)
343
+ end
344
+ end
345
+
346
+ def []=(key, value)
347
+ @data[key]=value
348
+ end
349
+
350
+ def [](key)
351
+ @data[key]
352
+ end
353
+
354
+ def keys
355
+ @data.keys
356
+ end
357
+
358
+ def has_key?(key)
359
+ @data.has_key?(key)
360
+ end
361
+
362
+ def attributes
363
+ @columns
364
+ end
365
+
366
+ end
367
+
368
+ class Grouping
369
+
370
+ def initialize(table, opts)
371
+ column_name = opts.fetch(:by)
372
+ order = opts.fetch(:order) { nil }
373
+ hash = {}
374
+ if column_name.to_sym == :metric # special optimized case
375
+ hash = table.group_by_metric
376
+ else
377
+ table.each do |row|
378
+ hash[row[column_name]] ||= Table.new(:column_names => row.attributes)
379
+ hash[row[column_name]] << row
380
+ end
381
+ end
382
+ if order
383
+ @arr = hash.sort_by &order
384
+ else
385
+ @arr = hash.to_a
386
+ end
387
+ end
388
+
389
+ def [](key)
390
+ @arr.each do |group_key, table|
391
+ return table if group_key == key
392
+ end
393
+ return nil
394
+ end
395
+
396
+ def each
397
+ @arr.each do |value, rows|
398
+ yield value, rows
399
+ end
400
+ end
401
+
402
+ end
403
+
404
+
@@ -0,0 +1,33 @@
1
+ require 'forwardable'
2
+
3
+ class Ranking
4
+ extend Forwardable
5
+
6
+ def initialize
7
+ @items_to_score = {}
8
+ end
9
+
10
+ def top(num=nil)
11
+ if(num.is_a?(Numeric))
12
+ sorted_items[0,num]
13
+ else
14
+ sorted_items
15
+ end
16
+ end
17
+
18
+ def percentile(item)
19
+ index = sorted_items.index(item)
20
+ worse_item_count = (length - (index+1))
21
+ worse_item_count.to_f/length
22
+ end
23
+
24
+ def_delegator :@items_to_score, :has_key?, :scored?
25
+ def_delegators :@items_to_score, :[], :[]=, :length, :each, :delete
26
+
27
+ private
28
+
29
+ def sorted_items
30
+ @sorted_items ||= @items_to_score.sort_by {|item, score| -score}.map {|item, score| item}
31
+ end
32
+
33
+ end
@@ -0,0 +1,43 @@
1
+ class RcovAnalyzer
2
+ include ScoringStrategies
3
+
4
+ COLUMNS = %w{percentage_uncovered}
5
+
6
+ def columns
7
+ COLUMNS
8
+ end
9
+
10
+ def name
11
+ :rcov
12
+ end
13
+
14
+ def map(row)
15
+ row.percentage_uncovered
16
+ end
17
+
18
+ def reduce(scores)
19
+ ScoringStrategies.average(scores)
20
+ end
21
+
22
+ def score(metric_ranking, item)
23
+ ScoringStrategies.identity(metric_ranking, item)
24
+ end
25
+
26
+ def generate_records(data, table)
27
+ return if data==nil
28
+ data.each do |file_name, info|
29
+ next if (file_name == :global_percent_run) || (info[:methods].nil?)
30
+ info[:methods].each do |method_name, percentage_uncovered|
31
+ location = Location.for(method_name)
32
+ table << {
33
+ "metric" => :rcov,
34
+ 'file_path' => file_name,
35
+ 'class_name' => location.class_name,
36
+ "method_name" => location.method_name,
37
+ "percentage_uncovered" => percentage_uncovered
38
+ }
39
+ end
40
+ end
41
+ end
42
+
43
+ end