attractor 2.6.0 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -22,6 +22,10 @@ module Attractor
22
22
  adapter.clear
23
23
  end
24
24
 
25
+ def reset!
26
+ adapter.reset!
27
+ end
28
+
25
29
  private
26
30
 
27
31
  def adapter
@@ -33,9 +37,16 @@ module Attractor
33
37
  module CacheAdapter
34
38
  class Base
35
39
  include Singleton
40
+
41
+ def reset!
42
+ # subclasses may override
43
+ end
36
44
  end
37
45
 
38
46
  class JSON < Base
47
+ SCHEMA_VERSION = 2
48
+ SCHEMA_KEY = "schema_version"
49
+
39
50
  def initialize
40
51
  super
41
52
 
@@ -43,14 +54,16 @@ module Attractor
43
54
  FileUtils.mkdir_p @data_directory
44
55
  FileUtils.touch filename
45
56
 
46
- begin
47
- @store = ::JSON.parse(File.read(filename))
48
- rescue ::JSON::ParserError
49
- @store = {}
50
- end
57
+ load_store
58
+ end
59
+
60
+ def reset!
61
+ load_store
51
62
  end
52
63
 
53
64
  def read(file_path:)
65
+ return nil if file_path == SCHEMA_KEY
66
+
54
67
  value_hash = @store[file_path]
55
68
 
56
69
  Value.new(**value_hash.values.first.transform_keys(&:to_sym)) unless value_hash.nil?
@@ -67,6 +80,7 @@ module Attractor
67
80
  end
68
81
 
69
82
  def persist!
83
+ @store[SCHEMA_KEY] ||= SCHEMA_VERSION
70
84
  File.write(filename, ::JSON.dump(@store))
71
85
  end
72
86
 
@@ -77,6 +91,23 @@ module Attractor
77
91
  def filename
78
92
  "#{@data_directory}/attractor-cache.json"
79
93
  end
94
+
95
+ private
96
+
97
+ def load_store
98
+ FileUtils.touch filename
99
+
100
+ begin
101
+ @store = ::JSON.parse(File.read(filename))
102
+ rescue ::JSON::ParserError
103
+ @store = {}
104
+ end
105
+
106
+ if @store[SCHEMA_KEY] != SCHEMA_VERSION
107
+ @store = {SCHEMA_KEY => SCHEMA_VERSION}
108
+ persist!
109
+ end
110
+ end
80
111
  end
81
112
  end
82
113
  end
@@ -8,14 +8,16 @@ module Attractor
8
8
  # calculates churn and complexity
9
9
  class BaseCalculator
10
10
  attr_reader :type
11
+ attr_accessor :files
11
12
 
12
- def initialize(file_prefix: "", ignores: "", file_extension: "rb", minimum_churn_count: 3, start_ago: "5y", verbose: false)
13
+ def initialize(file_prefix: "", ignores: "", file_extension: "rb", minimum_churn_count: 3, start_ago: "5y", verbose: false, files: nil)
13
14
  @file_prefix = file_prefix
14
15
  @file_extension = file_extension
15
16
  @minimum_churn_count = minimum_churn_count
16
17
  @start_date = Date.today - Attractor::DurationParser.new(start_ago).duration
17
18
  @ignores = ignores
18
19
  @verbose = verbose
20
+ @files = files
19
21
  end
20
22
 
21
23
  def calculate
@@ -27,29 +29,26 @@ module Attractor
27
29
  ignores: @ignores
28
30
  ).report(false)
29
31
 
30
- puts "Calculating churn and complexity values for #{churn[:churn][:changes].size} #{type} files" if @verbose
32
+ changes_by_path = churn[:churn][:changes].each_with_object({}) do |change, hash|
33
+ hash[change[:file_path]] = change
34
+ end
31
35
 
32
- values = churn[:churn][:changes].map do |change|
33
- history = git_history_for_file(file_path: change[:file_path])
34
- commit = history&.first&.first
36
+ target_paths = if @files && !@files.empty?
37
+ @files.uniq.select { |file_path| file_path.end_with?(".#{@file_extension}") }
38
+ else
39
+ changes_by_path.keys
40
+ end
35
41
 
36
- cached_value = Cache.read(file_path: change[:file_path])
42
+ puts "Calculating churn and complexity values for #{target_paths.size} #{type} files" if @verbose
37
43
 
38
- if !cached_value.nil? && !cached_value.current_commit.nil? && cached_value.current_commit == commit
39
- value = cached_value
40
- else
41
- complexity, details = yield(change)
44
+ values = target_paths.filter_map do |file_path|
45
+ change = changes_by_path[file_path]
42
46
 
43
- value = Value.new(file_path: change[:file_path],
44
- churn: change[:times_changed],
45
- complexity: complexity,
46
- details: details,
47
- history: history)
48
- Cache.write(file_path: change[:file_path], value: value)
47
+ if change
48
+ build_value(change) { |c| yield(c) if block_given? }
49
+ elsif @files && !@files.empty? && !File.exist?(file_path)
50
+ missing_value(file_path)
49
51
  end
50
-
51
- print "." if @verbose
52
- value
53
52
  end
54
53
 
55
54
  Cache.persist!
@@ -61,6 +60,39 @@ module Attractor
61
60
 
62
61
  private
63
62
 
63
+ def build_value(change)
64
+ if @files && !@files.empty? && !File.exist?(change[:file_path])
65
+ return missing_value(change[:file_path], churn: change[:times_changed])
66
+ end
67
+
68
+ history = git_history_for_file(file_path: change[:file_path])
69
+ commit = history&.first&.first
70
+
71
+ cached_value = Cache.read(file_path: change[:file_path])
72
+
73
+ if !cached_value.nil? && !cached_value.current_commit.nil? && cached_value.current_commit == commit
74
+ cached_value
75
+ elsif block_given?
76
+ complexity, details = yield(change)
77
+
78
+ value = Value.new(file_path: change[:file_path],
79
+ churn: change[:times_changed],
80
+ complexity: complexity,
81
+ details: details,
82
+ history: history)
83
+ Cache.write(file_path: change[:file_path], value: value)
84
+ value
85
+ end
86
+ end
87
+
88
+ def missing_value(file_path, churn: 0)
89
+ Value.new(file_path: file_path,
90
+ churn: churn,
91
+ complexity: nil,
92
+ details: [],
93
+ history: git_history_for_file(file_path: file_path))
94
+ end
95
+
64
96
  def git_history_for_file(file_path:, limit: 10)
65
97
  history = `git log --oneline -n #{limit} -- #{file_path}`
66
98
  history.split("\n")
data/lib/attractor/cli.rb CHANGED
@@ -7,13 +7,21 @@ require "attractor"
7
7
  module Attractor
8
8
  # contains methods implementing the CLI
9
9
  class CLI < Thor
10
+ SHARED_DEFAULTS = {
11
+ ignore: "",
12
+ minimum_churn: 3,
13
+ start_ago: "5y"
14
+ }.freeze
15
+
10
16
  shared_options = [[:file_prefix, aliases: :p],
11
17
  [:verbose, aliases: :v, type: :boolean],
12
- [:ignore, aliases: :i, default: ""],
18
+ [:ignore, aliases: :i],
19
+ [:files, type: :string],
13
20
  [:watch, aliases: :w, type: :boolean],
14
- [:minimum_churn, aliases: :c, type: :numeric, default: 3],
15
- [:start_ago, aliases: :s, type: :string, default: "5y"],
16
- [:type, aliases: :t]]
21
+ [:minimum_churn, aliases: :c, type: :numeric],
22
+ [:start_ago, aliases: :s, type: :string],
23
+ [:type, aliases: :t],
24
+ [:config, type: :string, default: ".attractor.yml", desc: "Path to .attractor.yml config file"]]
17
25
 
18
26
  advanced_options = [[:format, aliases: :f, default: "html"],
19
27
  [:no_open_browser, type: :boolean],
@@ -39,7 +47,7 @@ module Attractor
39
47
  end
40
48
  def init
41
49
  puts "Warming attractor cache"
42
- Attractor.init(calculators(options))
50
+ Attractor.init(calculators(effective_options))
43
51
  end
44
52
 
45
53
  desc "calc", "Calculates churn and complexity for all ruby files in current directory"
@@ -48,10 +56,13 @@ module Attractor
48
56
  end
49
57
  option(:format, aliases: :f, default: :table)
50
58
  def calc
51
- file_prefix = options[:file_prefix]
52
- output_format = options[:format]
59
+ require "attractor/reporters/console_reporter"
60
+
61
+ opts = effective_options
62
+ file_prefix = opts[:file_prefix]
63
+ output_format = opts[:format]
53
64
 
54
- report! Attractor::ConsoleReporter.new(file_prefix: file_prefix, ignores: options[:ignore], calculators: calculators(options), format: output_format)
65
+ report! Attractor::ConsoleReporter.new(file_prefix: file_prefix, ignores: opts[:ignore], calculators: calculators(opts), format: output_format), opts
55
66
  rescue RuntimeError => e
56
67
  puts "Runtime error: #{e.message}"
57
68
  end
@@ -61,10 +72,13 @@ module Attractor
61
72
  option(*option)
62
73
  end
63
74
  def report
64
- file_prefix = options[:file_prefix]
65
- open_browser = !(options[:no_open_browser] || options[:ci])
75
+ require "attractor/reporters/html_reporter"
76
+
77
+ opts = effective_options
78
+ file_prefix = opts[:file_prefix]
79
+ open_browser = !(opts[:no_open_browser] || opts[:ci])
66
80
 
67
- report! Attractor::HtmlReporter.new(file_prefix: file_prefix, ignores: options[:ignore], calculators: calculators(options), open_browser: open_browser)
81
+ report! Attractor::HtmlReporter.new(file_prefix: file_prefix, ignores: opts[:ignore], calculators: calculators(opts), open_browser: open_browser), opts
68
82
  rescue RuntimeError => e
69
83
  puts "Runtime error: #{e.message}"
70
84
  end
@@ -74,24 +88,83 @@ module Attractor
74
88
  option(*option)
75
89
  end
76
90
  def serve
77
- file_prefix = options[:file_prefix]
78
- open_browser = !(options[:no_open_browser] || options[:ci])
91
+ require "attractor/reporters/sinatra_reporter"
92
+
93
+ opts = effective_options
94
+ file_prefix = opts[:file_prefix]
95
+ open_browser = !(opts[:no_open_browser] || opts[:ci])
96
+
97
+ report! Attractor::SinatraReporter.new(file_prefix: file_prefix, ignores: opts[:ignore], calculators: calculators(opts), open_browser: open_browser), opts
98
+ end
79
99
 
80
- report! Attractor::SinatraReporter.new(file_prefix: file_prefix, ignores: options[:ignore], calculators: calculators(options), open_browser: open_browser)
100
+ desc "diff", "Calculates complexity delta between two git refs"
101
+ option :base, required: true
102
+ option :head, required: true
103
+ shared_options.each do |shared_option|
104
+ option(*shared_option)
105
+ end
106
+ option :format, aliases: :f, default: :table
107
+ def diff
108
+ require "attractor/diff_calculator"
109
+ require "attractor/reporters/diff_reporter"
110
+
111
+ opts = effective_options
112
+ file_list = parse_files(opts[:files])
113
+ file_list ||= default_diff_files(opts[:base], opts[:head])
114
+
115
+ data = Attractor::DiffCalculator.new(
116
+ base_ref: opts[:base],
117
+ head_ref: opts[:head],
118
+ files: file_list,
119
+ file_prefix: opts[:file_prefix],
120
+ minimum_churn_count: opts[:minimum_churn],
121
+ ignores: opts[:ignore],
122
+ start_ago: opts[:start_ago],
123
+ verbose: opts[:verbose],
124
+ type: opts[:type]
125
+ ).calculate
126
+
127
+ Attractor::DiffReporter.new(format: opts[:format]).report(data)
128
+ rescue ArgumentError, RuntimeError => e
129
+ puts "Runtime error: #{e.message}"
81
130
  end
82
131
 
83
132
  private
84
133
 
134
+ def effective_options
135
+ @effective_options ||= begin
136
+ config_options = Attractor::Config.load(options[:config])
137
+ cli_options = options.to_hash.transform_keys(&:to_sym).reject { |_, v| v.nil? }
138
+ merged = config_options.merge(cli_options)
139
+ SHARED_DEFAULTS.merge(merged) { |_key, default, val| val.nil? ? default : val }
140
+ end
141
+ end
142
+
85
143
  def calculators(options)
86
144
  Attractor.calculators_for_type(options[:type],
87
145
  file_prefix: options[:file_prefix],
88
146
  minimum_churn_count: options[:minimum_churn],
89
147
  ignores: options[:ignore],
90
148
  start_ago: options[:start_ago],
91
- verbose: options[:verbose])
149
+ verbose: options[:verbose],
150
+ files: parse_files(options[:files]))
151
+ end
152
+
153
+ def parse_files(value)
154
+ return nil if value.nil? || value.to_s.empty?
155
+
156
+ if value == "-"
157
+ $stdin.read.lines(chomp: true).reject(&:empty?)
158
+ else
159
+ value.split(",").map(&:strip).reject(&:empty?)
160
+ end
161
+ end
162
+
163
+ def default_diff_files(base_ref, head_ref)
164
+ `git diff --name-only #{base_ref}...#{head_ref}`.lines(chomp: true).reject(&:empty?)
92
165
  end
93
166
 
94
- def report!(reporter)
167
+ def report!(reporter, options)
95
168
  if options[:watch]
96
169
  puts "Listening for file changes..."
97
170
  reporter.watch
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Attractor
6
+ # Reads `.attractor.yml` configuration from disk and exposes it
7
+ # as a hash of CLI-compatible options.
8
+ class Config
9
+ DEFAULT_PATH = ".attractor.yml"
10
+
11
+ def self.load(path = DEFAULT_PATH)
12
+ new(path).to_options
13
+ end
14
+
15
+ def initialize(path = DEFAULT_PATH)
16
+ @path = path
17
+ @data = load_yaml
18
+ end
19
+
20
+ # Translates the YAML structure into options understood by the CLI.
21
+ # Unknown keys (e.g. branches, skip) are ignored because they are
22
+ # currently only relevant in CI/SaaS contexts.
23
+ def to_options
24
+ return {} unless @data
25
+
26
+ report = @data["report"] || {}
27
+
28
+ {
29
+ file_prefix: report["app_root"],
30
+ minimum_churn: report["minimum_churn"],
31
+ ignore: report["ignore"],
32
+ start_ago: report["start_ago"]
33
+ }.compact
34
+ end
35
+
36
+ private
37
+
38
+ def load_yaml
39
+ return {} unless File.exist?(@path)
40
+
41
+ contents = File.read(@path)
42
+ parsed = if Psych::VERSION >= "5.0"
43
+ YAML.safe_load(contents, permitted_classes: [], permitted_symbols: [], aliases: true)
44
+ else
45
+ YAML.safe_load(contents, [], [], true)
46
+ end
47
+
48
+ parsed || {}
49
+ rescue Psych::SyntaxError => e
50
+ raise Error, "Error parsing #{@path}: #{e.message}"
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "attractor"
4
+ require "attractor/git"
5
+
6
+ module Attractor
7
+ # Calculates complexity deltas between two git refs using temporary worktrees
8
+ class DiffCalculator
9
+ attr_reader :base_ref, :head_ref, :files, :options
10
+
11
+ def initialize(base_ref:, head_ref:, files: nil, **options)
12
+ @base_ref = base_ref
13
+ @head_ref = head_ref
14
+ @files = files
15
+ @options = options
16
+ end
17
+
18
+ def calculate
19
+ validate_refs!
20
+ file_list = @files || Git.diff_files(@base_ref, @head_ref)
21
+
22
+ base_worktree = Git::Worktree.new(@base_ref).checkout
23
+ head_worktree = Git::Worktree.new(@head_ref).checkout
24
+
25
+ base_values = calculate_in_worktree(base_worktree, file_list)
26
+ head_values = calculate_in_worktree(head_worktree, file_list)
27
+
28
+ Attractor::Cache.reset!
29
+ build_diff(base_values, head_values, file_list)
30
+ ensure
31
+ base_worktree&.cleanup
32
+ head_worktree&.cleanup
33
+ end
34
+
35
+ private
36
+
37
+ def validate_refs!
38
+ case [@base_ref, @head_ref]
39
+ in [nil | "", _]
40
+ raise ArgumentError, "base_ref is required"
41
+ in [_, nil | ""]
42
+ raise ArgumentError, "head_ref is required"
43
+ else
44
+ Git.validate_ref!(@base_ref)
45
+ Git.validate_ref!(@head_ref)
46
+ end
47
+ end
48
+
49
+ def calculate_in_worktree(worktree, files)
50
+ Attractor::Cache.reset!
51
+ worktree.chdir do
52
+ Attractor.calculators_for_type(@options[:type],
53
+ file_prefix: @options[:file_prefix],
54
+ minimum_churn_count: @options[:minimum_churn_count],
55
+ ignores: @options[:ignores],
56
+ start_ago: @options[:start_ago],
57
+ verbose: @options[:verbose],
58
+ files: files).transform_values(&:calculate)
59
+ end
60
+ end
61
+
62
+ def build_diff(base_values, head_values, files)
63
+ base_by_path = flatten_values(base_values)
64
+ head_by_path = flatten_values(head_values)
65
+
66
+ base_refactor_files = refactor_files(base_by_path.values)
67
+ head_refactor_files = refactor_files(head_by_path.values)
68
+
69
+ rows = files.uniq.map do |file_path|
70
+ build_row(file_path, base_by_path[file_path], head_by_path[file_path], base_refactor_files, head_refactor_files)
71
+ end
72
+
73
+ rows.sort_by! { |row| -row[:delta].abs }
74
+
75
+ total_score_base = base_by_path.values.sum { |value| value.score || 0 }
76
+ total_score_head = head_by_path.values.sum { |value| value.score || 0 }
77
+
78
+ {
79
+ base_ref: @base_ref,
80
+ head_ref: @head_ref,
81
+ total_score_base: total_score_base,
82
+ total_score_head: total_score_head,
83
+ trend: total_score_head - total_score_base,
84
+ files: rows
85
+ }
86
+ end
87
+
88
+ def flatten_values(values_by_type)
89
+ values_by_type.values.flatten.compact.each_with_object({}) do |value, hash|
90
+ hash[value.file_path] = value
91
+ end
92
+ end
93
+
94
+ def refactor_files(values)
95
+ Suggester.new(values).suggest.map(&:file_path)
96
+ end
97
+
98
+ def build_row(file_path, base_value, head_value, base_refactor_files, head_refactor_files)
99
+ complexity_base = base_value&.complexity
100
+ complexity_head = head_value&.complexity
101
+
102
+ delta = case [complexity_base, complexity_head]
103
+ in [nil, nil]
104
+ 0
105
+ in [nil, head]
106
+ head.to_f
107
+ in [base, nil]
108
+ -base.to_f
109
+ in [base, head]
110
+ head.to_f - base.to_f
111
+ end
112
+
113
+ {
114
+ file_path: file_path,
115
+ complexity_base: complexity_base,
116
+ complexity_head: complexity_head,
117
+ delta: delta,
118
+ churn: head_value&.churn,
119
+ score_head: head_value&.score,
120
+ refactor_base: base_refactor_files.include?(file_path),
121
+ refactor_head: head_refactor_files.include?(file_path),
122
+ details_base: base_value&.details,
123
+ details_head: head_value&.details
124
+ }
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Attractor
4
+ module Formatters
5
+ module FormatStrategy
6
+ private
7
+
8
+ def format_strategy(format)
9
+ case format.to_sym
10
+ when :csv then Formats::CSV.new
11
+ when :json then Formats::JSON.new
12
+ when :markdown then Formats::Markdown.new
13
+ when :table then Formats::Table.new
14
+ else
15
+ raise ArgumentError, "Unknown format: #{format}"
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "csv"
4
+
5
+ module Attractor
6
+ module Formatters
7
+ module Formats
8
+ class CSV
9
+ def call(report)
10
+ ::CSV.generate do |csv|
11
+ csv << report.columns
12
+ report.rows.each do |row|
13
+ csv << report.columns.map { |column| row[column] }
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Attractor
6
+ module Formatters
7
+ module Formats
8
+ class JSON
9
+ def call(report)
10
+ report.metadata.merge(
11
+ :title => report.title,
12
+ report.rows_key => rows_for(report),
13
+ :footer => report.footer
14
+ ).compact.to_json
15
+ end
16
+
17
+ private
18
+
19
+ def rows_for(report)
20
+ return report.rows unless report.group_by
21
+
22
+ report.rows.group_by { |row| row[report.group_by] }
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Attractor
4
+ module Formatters
5
+ module Formats
6
+ class Markdown
7
+ def call(report)
8
+ lines = []
9
+ lines << "# #{report.title}"
10
+ lines << ""
11
+
12
+ report.metadata.each do |key, value|
13
+ lines << "**#{format_key(key)}:** #{value}"
14
+ end
15
+ lines << "" unless report.metadata.empty?
16
+
17
+ lines << "| #{report.columns.join(" | ")} |"
18
+ lines << "| #{report.columns.map { |column| "-" * column.to_s.length }.join(" | ")} |"
19
+
20
+ report.rows.each do |row|
21
+ lines << "| #{report.columns.map { |column| row[column] }.join(" | ")} |"
22
+ end
23
+
24
+ if report.footer
25
+ lines << ""
26
+ lines << report.footer
27
+ end
28
+
29
+ lines.join("\n")
30
+ end
31
+
32
+ private
33
+
34
+ def format_key(key)
35
+ key.to_s.split("_").map(&:capitalize).join(" ")
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Attractor
4
+ module Formatters
5
+ module Formats
6
+ class Table
7
+ def call(report)
8
+ lines = []
9
+ lines << report.title
10
+ lines << ""
11
+
12
+ report.metadata.each do |key, value|
13
+ lines << "#{format_key(key)}: #{value}"
14
+ end
15
+ lines << "" unless report.metadata.empty?
16
+
17
+ widths = column_widths(report.columns, report.rows)
18
+ lines << format_line(report.columns, widths) { |column| column.to_s }
19
+ lines << format_line(report.columns, widths) { |_column| "-" }
20
+
21
+ report.rows.each do |row|
22
+ lines << format_line(report.columns, widths) { |column| row[column].to_s }
23
+ end
24
+
25
+ if report.footer
26
+ lines << ""
27
+ lines << report.footer
28
+ end
29
+
30
+ lines.join("\n")
31
+ end
32
+
33
+ private
34
+
35
+ def format_key(key)
36
+ key.to_s.split("_").map(&:capitalize).join(" ")
37
+ end
38
+
39
+ def column_widths(columns, rows)
40
+ columns.map do |column|
41
+ values = rows.map { |row| row[column].to_s.length }
42
+ [column.to_s.length, *values].max
43
+ end
44
+ end
45
+
46
+ def format_line(columns, widths)
47
+ columns.each_with_index.map do |column, index|
48
+ value = yield(column)
49
+ value.ljust(widths[index])
50
+ end.join(" ")
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end