attractor 2.6.0 → 2.7.1

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,29 @@ 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
+ # `file_extension` is a pattern, not a literal: the JavaScript calculator
38
+ # passes "(js|jsx)". `end_with?` would drop every file it lists.
39
+ extension = /\.(?:#{@file_extension})\z/
40
+ @files.uniq.select { |file_path| file_path.match?(extension) }
41
+ else
42
+ changes_by_path.keys
43
+ end
35
44
 
36
- cached_value = Cache.read(file_path: change[:file_path])
45
+ puts "Calculating churn and complexity values for #{target_paths.size} #{type} files" if @verbose
37
46
 
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)
47
+ values = target_paths.filter_map do |file_path|
48
+ change = changes_by_path[file_path]
42
49
 
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)
50
+ if change
51
+ build_value(change) { |c| yield(c) if block_given? }
52
+ elsif @files && !@files.empty? && !File.exist?(file_path)
53
+ missing_value(file_path)
49
54
  end
50
-
51
- print "." if @verbose
52
- value
53
55
  end
54
56
 
55
57
  Cache.persist!
@@ -61,6 +63,39 @@ module Attractor
61
63
 
62
64
  private
63
65
 
66
+ def build_value(change)
67
+ if @files && !@files.empty? && !File.exist?(change[:file_path])
68
+ return missing_value(change[:file_path], churn: change[:times_changed])
69
+ end
70
+
71
+ history = git_history_for_file(file_path: change[:file_path])
72
+ commit = history&.first&.first
73
+
74
+ cached_value = Cache.read(file_path: change[:file_path])
75
+
76
+ if !cached_value.nil? && !cached_value.current_commit.nil? && cached_value.current_commit == commit
77
+ cached_value
78
+ elsif block_given?
79
+ complexity, details = yield(change)
80
+
81
+ value = Value.new(file_path: change[:file_path],
82
+ churn: change[:times_changed],
83
+ complexity: complexity,
84
+ details: details,
85
+ history: history)
86
+ Cache.write(file_path: change[:file_path], value: value)
87
+ value
88
+ end
89
+ end
90
+
91
+ def missing_value(file_path, churn: 0)
92
+ Value.new(file_path: file_path,
93
+ churn: churn,
94
+ complexity: nil,
95
+ details: [],
96
+ history: git_history_for_file(file_path: file_path))
97
+ end
98
+
64
99
  def git_history_for_file(file_path:, limit: 10)
65
100
  history = `git log --oneline -n #{limit} -- #{file_path}`
66
101
  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,142 @@
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
+ file_types = build_file_types(base_values, head_values)
66
+
67
+ base_refactor_files = refactor_files(base_by_path.values)
68
+ head_refactor_files = refactor_files(head_by_path.values)
69
+
70
+ rows = files.uniq.map do |file_path|
71
+ build_row(file_path, base_by_path[file_path], head_by_path[file_path], base_refactor_files, head_refactor_files, file_types[file_path])
72
+ end
73
+
74
+ rows.sort_by! { |row| -row[:delta].abs }
75
+
76
+ total_score_base = base_by_path.values.sum { |value| value.score || 0 }
77
+ total_score_head = head_by_path.values.sum { |value| value.score || 0 }
78
+
79
+ {
80
+ base_ref: @base_ref,
81
+ head_ref: @head_ref,
82
+ total_score_base: total_score_base,
83
+ total_score_head: total_score_head,
84
+ trend: total_score_head - total_score_base,
85
+ files: rows
86
+ }
87
+ end
88
+
89
+ def flatten_values(values_by_type)
90
+ values_by_type.each_with_object({}) do |(_type, values), hash|
91
+ Array(values).compact.each do |value|
92
+ hash[value.file_path] = value
93
+ end
94
+ end
95
+ end
96
+
97
+ def build_file_types(base_values, head_values)
98
+ [base_values, head_values].each_with_object({}) do |values_by_type, hash|
99
+ values_by_type.each do |type, values|
100
+ Array(values).compact.each do |value|
101
+ hash[value.file_path] ||= type
102
+ end
103
+ end
104
+ end
105
+ end
106
+
107
+ def refactor_files(values)
108
+ Suggester.new(values).suggest.map(&:file_path)
109
+ end
110
+
111
+ def build_row(file_path, base_value, head_value, base_refactor_files, head_refactor_files, type)
112
+ complexity_base = base_value&.complexity
113
+ complexity_head = head_value&.complexity
114
+
115
+ delta = case [complexity_base, complexity_head]
116
+ in [nil, nil]
117
+ 0
118
+ in [nil, head]
119
+ head.to_f
120
+ in [base, nil]
121
+ -base.to_f
122
+ in [base, head]
123
+ head.to_f - base.to_f
124
+ end
125
+
126
+ {
127
+ file_path: file_path,
128
+ type: type,
129
+ complexity_base: complexity_base,
130
+ complexity_head: complexity_head,
131
+ delta: delta,
132
+ churn: head_value&.churn,
133
+ score_base: base_value&.score,
134
+ score_head: head_value&.score,
135
+ refactor_base: base_refactor_files.include?(file_path),
136
+ refactor_head: head_refactor_files.include?(file_path),
137
+ details_base: base_value&.details,
138
+ details_head: head_value&.details
139
+ }
140
+ end
141
+ end
142
+ 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,28 @@
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
+ :summary => report.sections.empty? ? nil : report.sections,
13
+ report.rows_key => rows_for(report),
14
+ :footer => report.footer
15
+ ).compact.to_json
16
+ end
17
+
18
+ private
19
+
20
+ def rows_for(report)
21
+ return report.rows unless report.group_by
22
+
23
+ report.rows.group_by { |row| row[report.group_by] }
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,82 @@
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 << "#{title_prefix(report)} #{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
+ report.sections.each do |section|
18
+ lines << "### #{section[:title]}"
19
+ lines << ""
20
+ lines += render_table(section[:columns], section[:rows])
21
+ lines << ""
22
+ end
23
+
24
+ if report.collapsed_table
25
+ lines << "<details>"
26
+ lines << "<summary>All files</summary>"
27
+ lines << ""
28
+ end
29
+
30
+ lines += render_table(report.columns, report.rows)
31
+
32
+ if report.collapsed_table
33
+ lines << ""
34
+ lines << "</details>"
35
+ end
36
+
37
+ if report.footer
38
+ lines << ""
39
+ lines << report.footer
40
+ end
41
+
42
+ lines.join("\n")
43
+ end
44
+
45
+ private
46
+
47
+ def render_table(columns, rows)
48
+ return [] if columns.empty?
49
+
50
+ lines = []
51
+ lines << "| #{columns.join(" | ")} |"
52
+ lines << "| #{columns.map { |column| "-" * column.to_s.length }.join(" | ")} |"
53
+
54
+ rows.each do |row|
55
+ lines << "| #{columns.map { |column| format_cell(column, row) }.join(" | ")} |"
56
+ end
57
+
58
+ lines
59
+ end
60
+
61
+ def format_cell(column, row)
62
+ value = row[column]
63
+
64
+ if column == :delta
65
+ return "new" if row[:complexity_base].nil? && !row[:complexity_head].nil?
66
+ return "deleted" if row[:complexity_head].nil? && !row[:complexity_base].nil?
67
+ end
68
+
69
+ value
70
+ end
71
+
72
+ def title_prefix(report)
73
+ (report.sections.any? || report.collapsed_table) ? "##" : "#"
74
+ end
75
+
76
+ def format_key(key)
77
+ key.to_s.split("_").map(&:capitalize).join(" ")
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end