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.
- checksums.yaml +4 -4
- data/README.md +35 -0
- data/app/assets/javascripts/index.pack.js +5 -5
- data/lib/attractor/cache.rb +36 -5
- data/lib/attractor/calculators/base_calculator.rb +51 -19
- data/lib/attractor/cli.rb +89 -16
- data/lib/attractor/config.rb +53 -0
- data/lib/attractor/diff_calculator.rb +127 -0
- data/lib/attractor/formatters/format_strategy.rb +20 -0
- data/lib/attractor/formatters/formats/csv.rb +20 -0
- data/lib/attractor/formatters/formats/json.rb +27 -0
- data/lib/attractor/formatters/formats/markdown.rb +40 -0
- data/lib/attractor/formatters/formats/table.rb +55 -0
- data/lib/attractor/formatters/formatter.rb +16 -0
- data/lib/attractor/formatters/report.rb +19 -0
- data/lib/attractor/formatters/targets/console.rb +47 -0
- data/lib/attractor/formatters/targets/diff.rb +25 -0
- data/lib/attractor/git.rb +51 -0
- data/lib/attractor/reporters/base_reporter.rb +1 -0
- data/lib/attractor/reporters/console_reporter.rb +13 -81
- data/lib/attractor/reporters/diff_reporter.rb +26 -0
- data/lib/attractor/suggester.rb +5 -2
- data/lib/attractor/value.rb +4 -1
- data/lib/attractor/version.rb +1 -1
- data/lib/attractor/watcher.rb +1 -1
- data/lib/attractor.rb +12 -8
- metadata +74 -8
data/lib/attractor/cache.rb
CHANGED
|
@@ -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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
32
|
+
changes_by_path = churn[:churn][:changes].each_with_object({}) do |change, hash|
|
|
33
|
+
hash[change[:file_path]] = change
|
|
34
|
+
end
|
|
31
35
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
42
|
+
puts "Calculating churn and complexity values for #{target_paths.size} #{type} files" if @verbose
|
|
37
43
|
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
18
|
+
[:ignore, aliases: :i],
|
|
19
|
+
[:files, type: :string],
|
|
13
20
|
[:watch, aliases: :w, type: :boolean],
|
|
14
|
-
[:minimum_churn, aliases: :c, type: :numeric
|
|
15
|
-
[:start_ago, aliases: :s, type: :string
|
|
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(
|
|
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
|
-
|
|
52
|
-
|
|
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:
|
|
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
|
-
|
|
65
|
-
|
|
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:
|
|
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
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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
|